Internationalisation

Mental model: Intl is a formatting service that already knows every locale. Your job is to hand it a number, a date or a list, and never to build the string yourself.

Level: intermediate · about 15 minutes

const amount = 1234.5;

for (const locale of ['en-GB', 'de-DE', 'fr-FR', 'ja-JP']) {
  const currency = { 'en-GB': 'GBP', 'de-DE': 'EUR', 'fr-FR': 'EUR', 'ja-JP': 'JPY' }[locale];
  console.log(locale, new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount));
}
// en-GB '£1,234.50'
// de-DE '1.234,50' plus the euro sign: comma decimal, dot grouping, symbol last
// ja-JP a yen symbol and no decimal places at all

The same amount, four locales, zero string building.

Intl is the built in internationalisation library. It ships with the engine, backed by the same CLDR data that operating systems use, so it knows that German swaps the comma and the dot, that Japanese yen has no minor unit, and that a French list joins with "et". None of that belongs in your code.

NumberFormat

const nf = (options) => new Intl.NumberFormat('en-GB', options);

console.log(nf({ maximumFractionDigits: 0 }).format(1234567));   // '1,234,568'
console.log(nf({ style: 'percent' }).format(0.256));             // '26%'
console.log(nf({ style: 'percent', maximumFractionDigits: 1 }).format(0.256)); // '25.6%'
console.log(nf({ notation: 'compact' }).format(1234567));        // '1.2M'
console.log(nf({ style: 'unit', unit: 'kilometer-per-hour' }).format(50)); // '50 km/h'
console.log(nf({ signDisplay: 'always' }).format(5));            // '+5'
console.log(nf({ minimumIntegerDigits: 2 }).format(7));          // '07'
const gbp = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' });

console.log(gbp.format(12.5)); // '£12.50'
console.log(gbp.formatToParts(12.5).map((p) => p.type + '=' + p.value).join(' '));
// currency=£ integer=12 decimal=. fraction=50

console.log(gbp.resolvedOptions().currency);              // 'GBP'
console.log(gbp.resolvedOptions().maximumFractionDigits); // 2, chosen from the currency

formatToParts is how you style part of a formatted value, and how you test one.

DateTimeFormat

const when = new Date('2024-03-05T14:07:00Z');

console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeZone: 'UTC' }).format(when));
// 'Tuesday, 5 March 2024'
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC' }).format(when));
// '05/03/2024, 14:07'
console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' }).format(when));
// '3/5/24', month first
console.log(new Intl.DateTimeFormat('en-GB', { timeStyle: 'short', timeZone: 'Asia/Tokyo' }).format(when));
// '23:07', the same instant in another zone
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone); // the user's zone

Hand rolled

const months = ['Jan', 'Feb', 'Mar'];
const label = months[d.getMonth()] +
  ' ' + d.getDate();
// one language, one order,
// and an array you must maintain

Intl

const label = new Intl.DateTimeFormat(
  locale,
  { day: 'numeric', month: 'short',
    timeZone: 'UTC' }
).format(d);
// every language, correct order

The hand rolled version also silently breaks the moment a translator asks for Japanese, where the order is year, month, day and the separators are different characters.

RelativeTimeFormat

const rtf = new Intl.RelativeTimeFormat('en-GB', { numeric: 'auto' });

console.log(rtf.format(-1, 'day'));   // 'yesterday', because numeric is 'auto'
console.log(rtf.format(1, 'day'));    // 'tomorrow'
console.log(rtf.format(-3, 'hour'));  // '3 hours ago'
console.log(rtf.format(3, 'week'));   // 'in 3 weeks'

const always = new Intl.RelativeTimeFormat('en-GB'); // numeric defaults to 'always'
console.log(always.format(-1, 'day')); // '1 day ago'

// You still choose the unit. Intl formats, it does not decide.
const pickUnit = (ms) => {
  const s = Math.round(ms / 1000);
  if (Math.abs(s) < 60) return [s, 'second'];
  if (Math.abs(s) < 3600) return [Math.round(s / 60), 'minute'];
  if (Math.abs(s) < 86400) return [Math.round(s / 3600), 'hour'];
  return [Math.round(s / 86400), 'day'];
};
console.log(rtf.format(...pickUnit(-2 * 3600 * 1000))); // '2 hours ago'
console.log(rtf.format(...pickUnit(-45 * 1000)));      // '45 seconds ago'

ListFormat

const items = ['tea', 'coffee', 'oat milk'];

console.log(new Intl.ListFormat('en-GB').format(items));
// 'tea, coffee and oat milk'
console.log(new Intl.ListFormat('en-US').format(items));
// 'tea, coffee, and oat milk', note the Oxford comma
console.log(new Intl.ListFormat('en-GB', { type: 'disjunction' }).format(['tea', 'coffee']));
// 'tea or coffee'
console.log(new Intl.ListFormat('en-GB', { style: 'narrow', type: 'unit' }).format(items));
// 'tea coffee oat milk'

PluralRules

const en = new Intl.PluralRules('en-US');
console.log(en.select(0), en.select(1), en.select(2)); // 'other' 'one' 'other'

const fr = new Intl.PluralRules('fr-FR');
console.log(fr.select(0), fr.select(1), fr.select(2)); // 'one' 'one' 'other'

const forms = { one: 'day', other: 'days' };
const say = (n, locale = 'en-US') =>
  n + ' ' + (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other);

console.log(say(1), say(2), say(0)); // '1 day' '2 days' '0 days'
console.log(new Intl.PluralRules('en-US', { type: 'ordinal' }).select(3)); // 'few', as in 3rd

The category is stable data. The words are yours.

CategoryUsed byExample
oneEnglish 1, French 0 and 11 day
otherthe fallback every locale has5 days
zeroWelsh, Latvian0 diwrnod
twoWelsh, Slovenian2 ddiwrnod
fewPolish, Russian, Arabic3 dni
manyPolish, Russian, Arabic25 dni

Collator: sorting text

const names = ['\u00c9mile', 'Adam', 'zoe', 'Zoe'];

console.log([...names].sort());
// code unit order: uppercase before lowercase, accents last

const collator = new Intl.Collator('en-GB', { sensitivity: 'base' });
console.log([...names].sort(collator.compare));
// 'Adam', 'Émile', then the two Zoes: human order

console.log(['a', 'B'].sort());                    // ['B', 'a'], 'B' is code unit 66
console.log(['a', 'B'].sort(collator.compare));    // ['a', 'B']

const natural = new Intl.Collator('en-GB', { numeric: true });
console.log(['item10', 'item2'].sort(natural.compare)); // ['item2', 'item10']
console.log(['item10', 'item2'].sort());                // ['item10', 'item2']

Segmenter: what a reader calls a character

const flag = '\u{1F1EC}\u{1F1E7}';   // two regional indicator letters, G and B
const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });

console.log(flag.length);                          // 4, UTF-16 code units
console.log([...flag].length);                     // 2, code points
console.log([...graphemes.segment(flag)].length);  // 1, what a reader sees

const hindi = '\u0928\u092e\u0938\u094d\u0924\u0947';
console.log(hindi.length, [...hindi].length, [...graphemes.segment(hindi)].length);
// 6 6 3

const words = new Intl.Segmenter('en', { granularity: 'word' });
console.log([...words.segment('Hello, world!')].filter((s) => s.isWordLike).map((s) => s.segment));
// ['Hello', 'world']

Three different counts, and only one of them matches what you see.

const s = '\u{1F1EC}\u{1F1E7}';
console.log(s.length, [...s].length);

Each regional indicator is one code point above U+FFFF, so it needs two UTF-16 units: length is 4. String iteration walks code points, so the spread gives 2. Only Intl.Segmenter with grapheme granularity reports the 1 thing a reader sees, which is what a character counter next to a text input should use.

NeedAPI
money, percentages, compact numbers, unitsIntl.NumberFormat
dates and times in any locale or zoneIntl.DateTimeFormat
"3 hours ago", "in 2 weeks"Intl.RelativeTimeFormat
"a, b and c"Intl.ListFormat
choosing between singular and plural wordsIntl.PluralRules
sorting and case or accent insensitive comparisonIntl.Collator
counting or slicing user visible charactersIntl.Segmenter
language and region names in their own languageIntl.DisplayNames
user locale
navigator.languages in the browser, Intl.DateTimeFormat().resolvedOptions().locale
stable output
pin the locale and pass timeZone
testing
assert on formatToParts or resolvedOptions, never the whole string
performance
construct formatters once, reuse them
shortcuts
num.toLocaleString, date.toLocaleDateString build a formatter each call
fallback
pass an array of locales, best match wins

Try it yourself

One value, every locale

const locales = ['en-GB', 'en-US', 'de-DE', 'fr-FR', 'ja-JP', 'ar-EG'];
const when = new Date('2024-03-05T14:07:00Z');

for (const locale of locales) {
  console.log(locale, {
    number: new Intl.NumberFormat(locale).format(1234567.891),
    date: new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(when),
    list: new Intl.ListFormat(locale).format(['a', 'b', 'c']),
    relative: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-1, 'day'),
    plural1: new Intl.PluralRules(locale).select(1),
    plural0: new Intl.PluralRules(locale).select(0),
  });
}

Add your own locale to the list. Then swap dateStyle for explicit day, month and weekday options and see which locales reorder the parts.

Count characters properly

const seg = typeof Intl.Segmenter === 'function'
  ? new Intl.Segmenter('en', { granularity: 'grapheme' })
  : null;

const samples = ['hello', '\u{1F1EC}\u{1F1E7}', 'e\u0301cole', '\u0928\u092e\u0938\u094d\u0924\u0947'];

for (const s of samples) {
  console.log({
    units: s.length,
    codePoints: [...s].length,
    graphemes: seg ? [...seg.segment(s)].length : 'no Segmenter here',
  });
}

// slice cuts mid-character, grapheme slicing does not
const flag = '\u{1F1EC}\u{1F1E7}';
console.log(JSON.stringify(flag.slice(0, 2)));
console.log(seg ? [...seg.segment(flag)].slice(0, 1).map((g) => g.segment).join('') : '');

Make a truncateGraphemes(text, max) that never cuts a character in half. Compare it with slice on the flag string.

Exercises

Pluralise with the real rules

Write pluralise(count, forms, locale = "en-US"). Use Intl.PluralRules to select the plural category for count in that locale, look the category up in forms (an object keyed by category names such as one and other), fall back to forms.other when the category is missing, and return the count and the word joined by a space.

Sort names like a human

Write sortNames(names, locale = "en-GB") which returns a new array sorted with Intl.Collator, configured so that accents and case do not change the order (sensitivity: "base") and embedded numbers sort numerically (numeric: true). Build the collator once per call, not once per comparison, and do not mutate the input.

Check yourself

What does this log?
one one other — French puts 0 and 1 in the one category, so "0 jour" is correct French and "0 jours" is not. English puts 0 in other. This is exactly why count === 1 ? singular : plural cannot be internationalised.
Why should a test never assert format(1234.5) === "£1,234.50"?
Because the exact output depends on the ICU and CLDR version in the runtime — Grouping separators, spacing around symbols and even the choice of space character change between ICU versions and CLDR releases. Assert on formatToParts types and values, on resolvedOptions, or compare two formatted values relative to each other.
You need to sort 20,000 product names for a UK user. What do you pass to sort?
a hoisted new Intl.Collator("en-GB").compare — The default sort compares UTF-16 code units, so it puts every capital before every lowercase letter and every accent at the end. localeCompare is correct but may rebuild a collator on each of the tens of thousands of comparisons. Build one collator and reuse its compare.
Which API tells you how many characters a reader sees in a string?
Intl.Segmenter with grapheme granularity — length counts UTF-16 code units, spreading counts code points, and normalize only rearranges combining marks. A user visible character (a grapheme cluster) can be several code points, so only Intl.Segmenter gives the number your character counter should display.

Common mistakes

  • Asserting on exact Intl output in tests, which breaks when the runtime updates its ICU data.
  • Omitting timeZone, so server and client render different strings for the same date.
  • Pluralising with count === 1, which is correct only in English.
  • Sorting user visible text with the default sort, which uses code unit order.
  • Creating a formatter inside a loop or a render function instead of hoisting it.
  • Passing integer pennies to NumberFormat and showing prices a hundred times too high.

Takeaways

  • Pin the locale and pass timeZone whenever the output must be reproducible.
  • NumberFormat derives decimal places from the currency code, so pass the code and a major unit amount.
  • PluralRules gives you the category; you supply the words for each one.
  • Collator is the only correct way to sort text a person will read, and it should be built once.
  • Segmenter counts graphemes, which is what a user calls a character.
  • Test formatToParts and resolvedOptions, never a whole formatted string.