skip to content

You own the formatting layer of a product shipped in twenty locales. How do you decide which locale each Intl formatter is constructed with, how do you verify what the runtime actually chose, and how do you keep formatting cheap on hot paths?

level: principalimportance: nice to knowfreq 22%

answer

  1. an ordered preference list, not one tag
  2. negotiate before you commit
  3. ask what was actually resolved
  4. fallback can be silent
  5. construction costs, formatting is cheap

basics

~20 s

Pass an ordered preference list, not a single tag; negotiate it with supportedLocalesOf against the runtime's data; confirm the outcome with resolvedOptions().locale, which can differ from what you asked for; and cache formatter instances keyed by locale plus options, since construction is the expensive part.

solid answer

~50 s

Treat the locale as an ordered preference list — user setting first, then the platform's language list, then a product default — and pass the whole array to the constructor, since every `Intl` constructor accepts one. Before committing, `Intl.NumberFormat.supportedLocalesOf(list)` tells you which entries the runtime actually has data for; `Intl.getCanonicalLocales` normalizes ill-formed tags. After construction, `resolvedOptions()` reports the truth: the resolved `locale`, plus the `numberingSystem`, `calendar` or `timeZone` chosen. Requesting `'de-AT'` may resolve to `'de'`, and a runtime built with trimmed locale data can silently fall back to `en-US` — so log or assert the resolved locale rather than trusting the request. On cost: constructing a formatter resolves locale data and is orders of magnitude more expensive than formatting with it, so memoize instances keyed by locale plus a stable serialization of the options, and format at the render edge only.

code

javascript · 10 lines
javascript
const requested = ['de-AT', 'de', 'en'];

console.log(Intl.NumberFormat.supportedLocalesOf(requested));
// the subset this runtime actually has data for

const nf = new Intl.NumberFormat(requested, { style: 'decimal' });
console.log(nf.resolvedOptions().locale);          // what it really chose
console.log(nf.resolvedOptions().numberingSystem); // e.g. "latn"

console.log(Intl.getCanonicalLocales(['EN-us']));  // ["en-US"]

go deeper

for a junior

Know that Intl constructors accept an array of locale tags in preference order, and that resolvedOptions() tells you which one was actually used.

for a middle

Explain negotiation concretely: supportedLocalesOf to filter a list, getCanonicalLocales to normalize tags, and the fact that a requested tag can resolve to a broader one. Say why the formatter, not the format call, is the cost.

for a senior

Show the operational instincts: assert resolved locales at boot, cache formatters by locale plus options, and keep a per-process cache from binding one user's locale to everyone. Be ready to explain the lookup versus best fit tradeoff.

for a principal

Own the strategy — where locale preference lives in the system, how Unicode extension subtags let one stored tag carry calendar, numbering and collation choices, why formatted text must never enter storage or APIs, and how runtime locale-data drift is managed as a pinned dependency.

## Locale is a negotiation, not a value Every `Intl` constructor accepts `string | string[]`, and the array is an ordered preference list. That is the interface to design around: ```js const prefs = [userSetting, ...platformLanguages, 'en'].filter(Boolean); const nf = new Intl.NumberFormat(prefs, { style: 'decimal' }); ``` Order the sources by authority: an explicit in-product setting beats the platform's language list, which beats a hardcoded product default. The final fallback should be a locale you have translations for, otherwise you resolve formatting correctly and render untranslated text next to it. ## Three inspection APIs **`Intl.getCanonicalLocales(tags)`** normalizes and validates: `Intl.getCanonicalLocales(['EN-us'])` returns `['en-US']`, and a structurally invalid tag throws a RangeError. Use it at the boundary where tags enter your system — from configuration, a URL segment, or a stored preference — so malformed values fail there rather than deep inside a formatter. **`supportedLocalesOf(list, options?)`** (a static method on every `Intl` constructor) returns the subset of the list the runtime has data for. Note that support is per-constructor in principle, and the `localeMatcher` option — `'lookup'` for the deterministic RFC 4647 algorithm, `'best fit'` (the default) for the implementation's own heuristic — changes what counts as a match. Choosing `'lookup'` buys predictability across runtimes at the cost of some quality. **`resolvedOptions()`** is the one people forget and the one that matters most. It reports what the constructed formatter actually settled on: ```js new Intl.DateTimeFormat(['de-AT']).resolvedOptions(); // { locale: 'de-AT' | 'de', calendar: 'gregory', numberingSystem: 'latn', // timeZone: 'Europe/Vienna', ... } ``` The `locale` field can differ from every tag you passed. On a runtime built with a trimmed locale data set, the difference can be a silent collapse to `en-US` — output that looks fine to an English-speaking reviewer and wrong to everyone else. A startup assertion comparing requested against resolved locales turns that into a deployment-time failure instead of a support ticket. ## Unicode extension subtags A locale tag can carry formatting choices, which keeps them out of your options objects: - `-u-nu-` numbering system — `'ar-EG-u-nu-latn'` forces Latin digits. - `-u-ca-` calendar — `'en-US-u-ca-iso8601'`. - `-u-co-` collation — `'de-DE-u-co-phonebk'`. - `-u-hc-` hour cycle — `'en-GB-u-hc-h23'`. This is the mechanism that lets a single stored preference express "German, phonebook collation, 24-hour clock" as one string. `Intl.Locale` parses and manipulates such tags programmatically — `new Intl.Locale('de-DE-u-co-phonebk')` exposes `language`, `region`, `baseName` and the extension-derived properties — which beats string surgery on tags. ## Cost and caching Construction resolves locale data; `format` on an existing instance is comparatively trivial. The measurable win is a memoized factory: ```js const cache = new Map(); function formatter(locale, options) { const key = locale + '|' + JSON.stringify(options); let f = cache.get(key); if (!f) { f = new Intl.NumberFormat(locale, options); cache.set(key, f); } return f; } ``` Two cautions. First, the key must be stable — `JSON.stringify` over an object literal with keys written in different orders produces different keys for the same options, so build options from a fixed-shape helper. Second, on a server the cache is per-process and shared across users, so it must be keyed by locale; a module-level formatter constructed once with the first request's locale is a genuine cross-user bug. ## The architectural rule Formatting is a *rendering* concern. Raw values — numbers, epoch instants, currency amounts in minor units, plus the currency code and the target time zone — travel through storage, APIs and caches. Formatting happens once, at the edge, against a negotiated locale. Violating this is expensive to undo: a formatted string in an API response cannot be re-rendered for another locale, cannot be compared or summed, and pins every downstream consumer to the producer's locale. It also breaks caching, because a response that embeds locale-specific text must be keyed by locale. ## What to verify at build and boot Three checks catch nearly all real incidents: 1. **Boot assertion** — for each shipped locale, construct the formatters you use and compare `resolvedOptions().locale` to the request; fail loudly on an unexpected fallback. 2. **Runtime data** — confirm the deployed runtime ships full locale data rather than a trimmed build, and treat that as a pinned dependency, since locale data changes with runtime upgrades. 3. **Test hygiene** — pin locale and time zone in tests, and assert on `formatToParts` rather than exact strings, so a data update that changes a space character does not fail the suite spuriously.

  • What is the difference between localeMatcher 'lookup' and 'best fit'?
    `'lookup'` is the deterministic RFC 4647 algorithm: truncate subtags from the right until a supported tag is found. `'best fit'`, the default, lets the implementation apply its own heuristics — it may return a locale it considers a better match than lookup would. `'best fit'` usually gives nicer results; `'lookup'` gives identical behaviour across runtimes, which matters when output must match between a server and a browser.
  • Why assert on resolvedOptions().locale at startup rather than trusting the requested tag?
    Because fallback is silent by design. A runtime built with a reduced locale data set accepts your `'pt-BR'` request and quietly resolves to `en-US`, producing plausible-looking output that is wrong for every Brazilian user. Comparing requested against resolved for each shipped locale at boot converts that into a deployment failure instead of a slow trickle of confused support tickets.
  • What breaks if formatted strings leak into API responses or caches?
    They stop being data. A formatted amount cannot be summed, compared or re-rendered for a different locale, so every consumer inherits the producer's locale choice. Caching degrades too, since any response containing locale-specific text must be keyed by locale, multiplying cache entries. Send the raw value plus the metadata needed to render it — currency code, time zone — and format at the edge.

saying these in an interview costs you the question

  • Passing a single locale tag instead of a preference list
  • Assuming the requested locale is the one that was used
  • Constructing one module-level formatter shared across users
  • Ignoring that trimmed runtime builds silently fall back
  • Returning formatted strings from APIs to save client work

context