Lesson 03·Unit 2 · Three failures you can see·Intl.* APIs and CLDR data·Lv 101·~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.
By the end
- ·Format money with each currency's own minor units, so JPY rounds to whole yen without you hardcoding two decimals.
- ·Explain why one options object renders differently everywhere: field order, digits, calendars, and grouping are CLDR data, not styling.
- ·Predict the characters Intl emits, including fr-FR's narrow no-break space, and stop hand-typing expected bytes into tests.
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. Get this wrong and you lose users before they reach the conversion funnel, and you'll never see the bounce in your funnel attribution.
How it works
You request fields, and the locale decides everything else
Intl.DateTimeFormat has no format string. That is a design decision. You declare which fields you need (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, silently.
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): 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 you did not know existed.
How it works
The week is locale data too
Formatting a date is only half the calendar. The other half is the grid it sits in, and that grid is locale data. 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 you can watch the start column and the shaded weekend move. Engines still disagree on the API shape, some exposing weekInfo as a getter and newer ones a getWeekInfo() method, so feature-detect both.
How it works
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 for you. It does not. Pass unit: "kilometer" to an en-US formatter and you get 5 km, while a US reader expects miles. The formatter localizes the label and never converts the quantity.
Two decisions stay with your 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, so you map it yourself. Second, the conversion (kilometers to miles, Celsius to Fahrenheit) is plain arithmetic you run before formatting. The demo's converter formats both figures with Intl and runs the arithmetic by hand in between.
See it yourself
Try these, in order
Each step triggers a specific failure you should recognize on sight.
- 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. Grouping is locale data: the shared language does not decide it.
- 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. The hour cycle is locale data. Forcing h23 flattens every row to 24-hour: the locale supplies the default, and the override comes from you.
- 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. This is the point of the fields-not- format-strings contract: you change 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). Only the presentation moves. 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). Also watch th-TH's year: Thailand's default calendar is Buddhist, so 2026 renders as 2569.
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' }) |
| 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. Suppose your payment code stores amounts in "minor units" (cents) and multiplies by 100. Then you overcharge Japanese customers 100× and undercharge 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 你?"
The Chinese characters break into individual words: ICU's CJK word-boundary rules at work.
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), not universal. A calendar grid or a "this weekend" label that hardcodes Sunday-start / Sat-Sun is wrong across much of the world.
The week starts on Sun. The weekend is Sat + Sun. (The US starts Sunday, most of Europe Monday. Much of the Middle East starts Saturday, with a Friday-Saturday weekend.)
Measurement systems
Intl formats the unit you hand it, but it never converts a value or chooses a system. That decision is yours: map the locale to a system, convert, then format.
The current locale en-US maps to US customary. Intl formatted both figures above. The conversion between them was plain arithmetic that no formatter does for you.
If you remember one thing
Never hand-format numbers, dates, or money. Pass a locale and options to Intl.* and ship exactly what it returns, including the characters you did not expect.
What to do about it
- Never roll your own 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 imperial, 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).
Use this with
Stakeholders
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.
Quick check
3 questions · pass at 2+
Question 1/3
What does new Intl.NumberFormat('ja-JP', {style:'currency', currency:'JPY'}).format(1234.56) produce?
Question 2/3
How does en-IN (English, India) group the number 1234567?
Question 3/3
Which character does fr-FR use as the thousands separator in Intl output?