Chapter 03·Three visible failures·~8 min
Dates, times, numbers & currency
1,234.56 in the US. 1.234,56 in Germany. 1'234.56 in Switzerland. 1,23,456 in India. ¥1,235 in Japan (no fractional digits). One number, twenty locales; Intl.* and CLDR already know the rules.
The problem
A signup page that says $1,234.56 on 12/31/2024 reads as a typo in Berlin, as an error in Tokyo, and is illegible in Riyadh. Currency precision varies (JPY = 0 decimal places, BHD = 3), thousand separators differ (, vs . vs '), digits themselves can be Latin or Arabic-Indic. Nothing crashes when the convention is wrong: the page renders, the numbers read wrong, and nothing is logged.
How it works
The caller requests fields, and the locale decides everything else
Intl.DateTimeFormat has no format string. The caller declares which fields it needs (a date, a time, at what verbosity), and CLDR supplies the locale's own pattern: the field order, the separators, the digits, even the calendar. The API's contract is "tell me what to say, not how to say it".
That is why one options object renders as 4/21/26, 6:30 PM in en-US and 21.04.26, 18:30 in de-DE: month-first versus day-first, 12-hour versus 24-hour, slash versus dot, all locale data. The differences go past the cosmetic: ar-EG writes the same date in Arabic-Indic digits by default, and th-TH renders 2026 as 2569 because Thailand's default calendar is Buddhist. A hand-rolled MM/DD/YY template gets every one of those wrong.
Numbers follow the same contract. Grouping (1,234,567 versus India's lakh-crore 12,34,567), decimal separators, currency symbol position, and compact notation (1.2M in English, 123.5万 in Japanese, 12.3 लाख in Hindi, with one fraction digit allowed): CLDR ships every one of these per locale. Keep amounts as numbers until the last possible moment, then let the formatter place every character, including the ones few engineers know exist.
The week is locale data too
Formatting a date is only half the calendar. The other half is the grid it sits in. Intl.Locale(...).weekInfo reports the first day of the week and which days are the weekend. Both vary widely: the United States starts the week on Sunday, most of Europe on Monday, and much of the Middle East on Saturday with a Friday-Saturday weekend.
This is the field a hand-built date picker or a "this weekend" reminder gets wrong. Hardcode a Sunday-first grid with a Saturday-Sunday weekend and it is incorrect for a Cairo or Riyadh user. The week strip in the demo re-lays-out per locale, so the start column and the shaded weekend visibly move. Engines still disagree on the API shape, some exposing weekInfo as a getter and newer ones a getWeekInfo() method, so feature-detect both.
Intl formats units, but will not convert or choose them
Intl.NumberFormat with a unit style renders 5 km or 3.1 mi in the locale's own way, and it is easy to assume it handles measurement end to end. It does not. Pass unit: "kilometer" to an en-US formatter and the output is 5 km, while a US reader expects miles. The formatter localizes the label and never converts the quantity.
Two decisions stay with application code. First, which system a locale uses: only the United States, Liberia, and Myanmar are US-customary, the UK is mixed (miles on the road, Celsius on the forecast), and nearly everywhere else is metric. CLDR records this, but Intl does not expose it. Second, the conversion (kilometers to miles, Celsius to Fahrenheit) is plain arithmetic that runs before formatting. The demo's converter formats both figures with Intl and runs the arithmetic by hand in between.
The demo
Try these, in order
Each step reproduces one specific failure in the demo below.
- 1In the money section, switch the currency from USD to JPY, keeping the same amount. The fraction digits disappear and the value rounds (¥ has 0 minor units: there are no “cents” of yen). Then read the minor-units callout: BHD has three.
- 2Set the locale to ar-EG and re-read every output. Digits become Arabic-Indic (١٢٣…), separators change, and the resolved BCP-47 tag at the top shows what the negotiation produced.
- 3Set the calendar dropdown to “Buddhist (Thai)”. The year jumps to 2569: same instant, different calendar. Users in Thailand see Buddhist-era years in government and daily contexts.
- 4In the four-locale hero, compare en-US and en-IN for the same amount. Same language, different grouping: 1,234,567.89 vs 12,34,567.89. India groups by lakh and crore, and grouping follows the locale rather than the language.
- 5In the locale matrix (Date & time mode), set Date style to none and Time style to short, and read the rows. Then force Hour cycle to h23. Under locale default, en-US reads 6:30 PM while en-GB, de-DE, and ja-JP read 18:30. Forcing h23 flattens every row to 24-hour: the locale supplies the hour cycle, and the override comes from the caller.
- 6Switch the locale matrix to Currency mode and step Symbol display through symbol → code → name. One amount, three audiences: a symbol for UI, an unambiguous code (JPY) for finance exports, and a spelled-out name in each row's own language (“Japanische Yen” in de-DE) for screen readers and legal text.
Resolved BCP-47 tag: en-US
The same number, four locales that look nothing alike
en-US
$1,234,567.89
US (baseline)
en-IN
$12,34,567.89
Indian grouping
de-CH
$ 1'234'567.89
Swiss apostrophe
ar-EG
١٬٢٣٤٬٥٦٧٫٨٩ US$
Arabic-Indic digits
The locale matrix: flip a toggle, watch eleven locales react
One option set drives every row. That is the fields-not-format-strings contract: the caller changes what to say, each locale re-decides how.
| Locale | Output | What moved |
|---|---|---|
| en-US | Apr 21, 2026, 6:30 PM | month first, 12-hour |
| en-GB | 21 Apr 2026, 18:30 | day first, 24-hour |
| de-DE | 21.04.2026, 18:30 | dots group, comma decimal |
| fr-FR | 21 avr. 2026, 18:30 | narrow no-break spaces |
| pt-BR | 21 de abr. de 2026, 18:30 | |
| ru-RU | 21 апр. 2026 г., 18:30 | |
| hi-IN | 21 अप्रैल 2026, 6:30 pm | lakh/crore grouping |
| th-TH | 21 เม.ย. 2569 18:30 | Buddhist calendar by default |
| ja-JP | 2026/04/21 18:30 | year first |
| zh-CN | 2026年4月21日 18:30 | year first |
| ar-EG | ٢١/٠٤/٢٠٢٦، ٦:٣٠ م | Arabic-Indic digits, RTL |
Same instant in every row, pinned to UTC. Watch the field order (4/21 vs 21.04 vs 2026/04/21) and the hour cycle (en-US says 6:30 PM where en-GB says 18:30). th-TH's year reads 2569: Thailand's default calendar is Buddhist.
Numbers & currency
| Style | Output | Code |
|---|---|---|
| Decimal | 1,234,567.89 | NumberFormat() |
| Currency | $1,234,567.89 | NumberFormat({ style: 'currency', currency: 'USD' }) |
| Compact | 1.2M | NumberFormat({ notation: 'compact', maximumFractionDigits: 1 }) |
| Percent (0.7321) | 73.21% | NumberFormat({ style: 'percent' }) |
Minor-units trap (ISO 4217 e-table)
Most currencies have 2 decimals, but JPY, KRW, VND, CLP have 0 and BHD, KWD, OMR, JOD have 3. Payment code that stores amounts in "minor units" (cents) by multiplying by 100 overcharges Japanese customers 100× and undercharges Bahrainis 10×.
Use new Intl.NumberFormat(locale, {style:'currency',currency}).resolvedOptions().minimumFractionDigits to read the correct count.
Units
| Style | Output | Code |
|---|---|---|
| Unit (long) | 42.5 kilometers | NumberFormat({ style: 'unit', unitDisplay: 'long' }) |
| Unit (narrow) | 42.5km | NumberFormat({ style: 'unit', unitDisplay: 'narrow' }) |
Dates & times
| Style | Output | Code |
|---|---|---|
| short | 4/21/26 | { dateStyle: 'short' } |
| medium | Apr 21, 2026 | { dateStyle: 'medium' } |
| long | April 21, 2026 | { dateStyle: 'long' } |
| full + time | Tuesday, April 21, 2026 at 6:30:00 PM UTC | { dateStyle: 'full', timeStyle: 'long' } |
Relative time
| Style | Output | Code |
|---|---|---|
| auto | 3 days ago | RelativeTimeFormat({ numeric: 'auto' }) |
List formatting
| Style | Output | Code |
|---|---|---|
| Conjunction | apples, oranges, and pears | ListFormat({ type: 'conjunction' }) |
| Disjunction | apples, oranges, or pears | ListFormat({ type: 'disjunction' }) |
Display names
| Style | Output | Code |
|---|---|---|
| Country 'DE' | Germany | DisplayNames({ type: 'region' }) |
| Language 'zh-Hant' | Traditional Chinese | DisplayNames({ type: 'language' }) |
| Currency 'JPY' | Japanese Yen | DisplayNames({ type: 'currency' }) |
Segmenter (graphemes & words)
Input
"👩👩👧👦 こんにちは"
Graphemes (7)
Words from "Hello, 世界! How are 你?"
ICU's CJK word-boundary rules break the Chinese characters into individual words.
The same amount, formatted for every supported locale
First day of the week & weekend
Which day a week starts on, and which days are the weekend, are locale data (Intl.Locale.weekInfo). A calendar grid or a "this weekend" label that hardcodes Sunday-start / Sat-Sun is wrong in Germany (Monday start) and in Saudi Arabia (Friday–Saturday weekend).
The week starts on Sun. The weekend is Sat + Sun. (The US starts Sunday, most of Europe Monday, much of the Middle East Saturday.)
Measurement systems
Intl formats the unit it is handed, but it never converts a value or chooses a system. That decision stays with application code: map the locale to a system, convert, then format.
The current locale en-US maps to US customary.
The short version
Never hand-format numbers, dates, or money. Pass a locale and options to Intl.* and ship exactly what it returns, including the unexpected characters.
What to do about it
- Never hand-roll a formatter. Always use
Intl.NumberFormat,Intl.DateTimeFormat, andIntl.RelativeTimeFormatagainst the user's negotiated locale. They handle currency precision, digit substitution, calendar systems, and DST. - For currency, pass
{ style: "currency", currency: "JPY" }and let the API decide the digit count. Don't hardcode.toFixed(2). - Switch the BCP-47 tag's
u-ca-extension to support non-Gregorian calendars (Japanese eras, Hijri, Buddhist) when launching in regions where those are the civil calendar. - Use
Intl.Segmenterwithgranularity: "word"to find word boundaries in Chinese, Japanese, and Thai (no whitespace required).
Where this comes up
Who it concerns
Moments
- ·Designing a checkout, invoice, or analytics dashboard
- ·Launching into a market with a non-Gregorian calendar
- ·Component library review
Field note
A common silent failure: French number output uses U+202F (narrow no-break space) as the group separator in “1 234,56”, instead of a regular space. Screenshot diffs pass, string comparisons in tests fail, and a well-meaning engineer “fixes” it with replace(' ', ''), which does not match either. A test that asserts against hand-typed expected output asserts the wrong bytes.