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 allThe 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 currencyformatToParts 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 orderThe 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 3rdThe category is stable data. The words are yours.
| Category | Used by | Example |
|---|---|---|
one | English 1, French 0 and 1 | 1 day |
other | the fallback every locale has | 5 days |
zero | Welsh, Latvian | 0 diwrnod |
two | Welsh, Slovenian | 2 ddiwrnod |
few | Polish, Russian, Arabic | 3 dni |
many | Polish, Russian, Arabic | 25 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.
| Need | API |
|---|---|
| money, percentages, compact numbers, units | Intl.NumberFormat |
| dates and times in any locale or zone | Intl.DateTimeFormat |
| "3 hours ago", "in 2 weeks" | Intl.RelativeTimeFormat |
| "a, b and c" | Intl.ListFormat |
| choosing between singular and plural words | Intl.PluralRules |
| sorting and case or accent insensitive comparison | Intl.Collator |
| counting or slicing user visible characters | Intl.Segmenter |
| language and region names in their own language | Intl.DisplayNames |
user localenavigator.languagesin the browser,Intl.DateTimeFormat().resolvedOptions().localestable output- pin the locale and pass
timeZone testing- assert on
formatToPartsorresolvedOptions, never the whole string performance- construct formatters once, reuse them
shortcutsnum.toLocaleString,date.toLocaleDateStringbuild a formatter each callfallback- 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
onecategory, so "0 jour" is correct French and "0 jours" is not. English puts 0 inother. This is exactly whycount === 1 ? singular : pluralcannot 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
formatToPartstypes and values, onresolvedOptions, 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.localeCompareis correct but may rebuild a collator on each of the tens of thousands of comparisons. Build one collator and reuse itscompare. - Which API tells you how many characters a reader sees in a string?
Intl.Segmenterwith grapheme granularity —lengthcounts UTF-16 code units, spreading counts code points, andnormalizeonly rearranges combining marks. A user visible character (a grapheme cluster) can be several code points, so onlyIntl.Segmentergives the number your character counter should display.
Common mistakes
- Asserting on exact
Intloutput 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
NumberFormatand showing prices a hundred times too high.
Takeaways
- Pin the locale and pass
timeZonewhenever the output must be reproducible. NumberFormatderives decimal places from the currency code, so pass the code and a major unit amount.PluralRulesgives you the category; you supply the words for each one.Collatoris the only correct way to sort text a person will read, and it should be built once.Segmentercounts graphemes, which is what a user calls a character.- Test
formatToPartsandresolvedOptions, never a whole formatted string.