JAVASCRIPT / DATES, TIME, AND INTERNATIONALIZATION
Intl for numbers, dates, and list formatting
Format numbers, currencies, dates, and lists for any locale with Intl.NumberFormat, DateTimeFormat, and ListFormat instead of hand-built string glue.
What you will learn
- Build one Intl formatter and reuse it; the constructor, not format(), is the slow part.
- Pass timeZone explicitly so Intl.DateTimeFormat output is host-independent.
- Reach for formatToParts instead of regexing formatted output.
- Let ListFormat pick 'a and b' vs 'a, b, and c' instead of joining by hand.
Understanding Intl for numbers, dates, and list formatting
Every Intl constructor does two jobs: it negotiates a locale from the tags you pass against the data the engine actually ships, then it compiles your option bag into one fixed pattern. That resolution is the expensive half; the later .format() call is a cheap walk over an already-chosen pattern. That split is why the API is a constructor plus a method rather than a single standalone function, and it tells you the shape to write: one formatter created once, reused for thousands of values.
You describe intent, and the locale data decides characters and order. Writing { style: 'currency', currency: 'EUR' } means "this is an amount of euros", and CLDR decides whether the symbol leads or trails, whether the decimal mark is a comma or a period, and whether a four-digit number gets a thousands separator at all — es-ES only starts grouping at five digits. Lists work the same way: ListFormat knows English wants "a and b" for two items but "a, b, and c" for three, which is exactly the rule a hand-rolled join gets wrong. Call resolvedOptions() to see what the engine actually settled on, including the calendar, the numbering system, and the time zone DateTimeFormat picked up from the host when you did not name one.
The output is for eyes only. Because it comes from ICU/CLDR data bundled with the engine, the exact bytes can change between versions: current en-US data separates the time from PM with U+202F, a narrow no-break space, which broke a wave of snapshot tests when it landed. So never parse a formatted string back into a number or date and never store it; when you need structure — wrapping the currency symbol in a span, or swapping "$" for "USD" — call formatToParts and work with the same pieces the formatter used.
const items = ['tea', 'coffee', 'cocoa'];
const total = 48.5;
const shipped = new Date(Date.UTC(2026, 8, 3));
function receipt(locale, currency) {
const list = new Intl.ListFormat(locale, { type: 'conjunction' });
const money = new Intl.NumberFormat(locale, { style: 'currency', currency });
const day = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' });
return `${list.format(items)}: ${money.format(total)} (${day.format(shipped)})`;
}
for (const [locale, currency] of [['en-US', 'USD'], ['es-ES', 'EUR'], ['de-DE', 'EUR']]) {
console.log(receipt(locale, currency));
}
const r = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' }).resolvedOptions();
console.log(r.locale, r.calendar, r.numberingSystem, r.timeZone);An Intl formatter is a reusable handle on the engine's locale data: you declare intent through options, the locale decides the characters and their order, and the result is display-only.
Worked examples
List connectors are locale rules, not string joins
Shows how ListFormat changes punctuation with item count and list type.
const and = new Intl.ListFormat('en-US', { type: 'conjunction' });
const or = new Intl.ListFormat('en-US', { type: 'disjunction' });
console.log(and.format(['red']));
console.log(and.format(['red', 'green']));
console.log(and.format(['red', 'green', 'blue']));
console.log(or.format(['red', 'green', 'blue']));
console.log(new Intl.ListFormat('en-US', { type: 'unit', style: 'narrow' }).format(['5 ft', '9 in']));Example explained
Line 1A one-item list comes back untouched: no connector, no comma.
Line 2Two items get "and" with no comma, while three items pick up the Oxford comma from the en-US end pattern — two different patterns from the same instance.
Line 3type: 'disjunction' swaps in "or", so the connector word never appears in your source.
Line 4type: 'unit' with style: 'narrow' joins with a bare space, which is what compound measurements need.
formatToParts instead of string surgery
Rebuilds a formatted currency value with a different symbol without touching separators.
const nf = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const parts = nf.formatToParts(-1234.5);
console.log(parts.map(p => p.type).join(' '));
console.log(parts.map(p => (p.type === 'currency' ? 'USD ' : p.value)).join(''));Example explained
Line 1The part types arrive in the order the locale chose: sign first, then symbol, then digits — de-DE would order them differently for the same options.
Line 2'group' and 'decimal' are separate parts, so you can restyle or drop them without guessing which character this locale used.
Line 3Replacing only the 'currency' part yields "USD" while grouping and the two fraction digits stay locale-correct.
Line 4Joining the untouched parts reproduces nf.format(-1234.5) exactly, which is what makes this safe.
Relative time with idiomatic words
Contrasts numeric: 'auto' with the default numeric: 'always'.
const auto = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
const always = new Intl.RelativeTimeFormat('en-US');
console.log(auto.format(-1, 'day'), '|', always.format(-1, 'day'));
console.log(auto.format(0, 'day'), '|', always.format(0, 'day'));
console.log(auto.format(3, 'day'), '|', always.format(3, 'day'));
console.log(auto.format(-2, 'month'), '|', always.format(-2, 'month'));Example explained
Line 1numeric: 'auto' lets the locale substitute a word, so -1 day becomes "yesterday" instead of "1 day ago".
Line 2Zero has its own word too, which is why special-casing 0 in your own code is redundant and usually wrong in other locales.
Line 3The sign carries direction: positive is future ("in 3 days"), negative is past — there is no separate flag.
Line 4The default numeric: 'always' keeps the digit, which is what you want in a table where every row must line up.
Important notes
Currency style never converts money: { style: 'currency', currency: 'JPY' } formats 48.5 as ¥49 because JPY carries zero fraction digits. The presentation and the display rounding change; the amount you passed does not.
date.toLocaleDateString('de-DE', options) accepts the same option bags, but builds and discards a formatter on every call — fine for one-off strings, wasteful inside loops.
Common mistakes
Constructing the formatter inside the loop, as in rows.map(r => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(r.total)): locale resolution runs once per row, so a few thousand rows spend their time negotiating locales instead of painting. Hoisting the formatter removes the cost.
Assembling dates by hand with `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`. That hardcodes English word order and punctuation, so German readers see "September 3, 2026" instead of "3. September 2026", and you now own a month-name table forever.
Reading formatted output back as data, such as Number(price.replace(/,/g, '')). It happens to work in en-US and silently corrupts de-DE, where the comma is the decimal mark, so 48,50 becomes 4850.
Try it yourself
Change, predict, then run
In a browser console, write one function that takes a locale tag and logs ['Ana', 'Ben', 'Chu'] as a conjunction list, 1999.9 as a currency amount, and new Date(Date.UTC(2026, 0, 31)) with dateStyle 'full' and timeZone 'UTC'. Call it for 'en-US', 'de-DE', and 'ja-JP', and note which locale puts the year first and which one changes where the currency symbol sits.
Open the JavaScript workspaceCheck your understanding
A test asserts that new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }).format(d) equals the literal string "September 3, 2026 at 8:31 PM". It passes on your laptop and fails in CI. What is the most likely cause?
- CI's TZ environment variable is not UTC, so the formatted hour shifted.
- timeStyle: 'short' drops seconds, so CI rounds the minute differently.
- CI ships a different ICU/CLDR data version, and current en-US data puts a narrow no-break space (U+202F) before PM.
- Both engines resolve 'en-US' down to 'en', and 'en' formats long dates as "3 September 2026".
Show answer
The string is built from ICU/CLDR data bundled with the engine, and that data changed the separator before AM/PM to U+202F, so a byte-for-byte comparison fails even though the formatting is correct. The TZ answer is the usual suspect for date bugs, but timeZone: 'UTC' in the option bag overrides the host zone, so both machines compute identical wall-clock fields. Assert on formatToParts, or build the expected string with the same formatter, rather than pinning a literal.