Chapter 05·The Intl toolbox·~6 min
Lists, ranges & durations
Joining values is grammar. English writes 'A, B, and C', German 'A, B und C', Japanese A、B、C with no conjunction at all. Date ranges collapse shared fields per locale. Intl.ListFormat, formatRange, and selectRange carry the rules.
The problem
Two lines of code look harmless: items.join(", ") for a list, and format(start) + " – " + format(end) for a range. Both hardcode English grammar. The join ships the wrong connective and comma policy to every other market; the range repeats fields the locale would have collapsed, uses the wrong dash, and skips the plural question: "0–1 items" takes a different plural category in French than in English.
How it works
Even “and” is locale data
Joining three items into a sentence feels like string work. It is grammar. Locales differ in every component: the separator (Japanese uses the ideographic comma 、), the final connective (English "and", German "und", Arabic و, or none: Japanese joins A、B、C with no conjunction), and whether a comma precedes that connective (English long style writes "A, B, and C", German writes "A, B und C"). No template has slots for all of that.
So CLDR stores list patterns per locale, in three flavors: conjunction ("and"-lists), disjunction ("or"-lists), and unit lists ("5 ft, 7 in": no connective, tighter separators). Intl.ListFormat picks the right pattern for the locale and the flavor. Application code supplies the items. The moment the ", " lives in code instead, English grammar ships to every market.
A range is not two values and a dash
The natural implementation of "Jan 3 – 5, 2026" formats both dates and joins them with a dash. It loses three things CLDR encodes. English collapses the shared month and year and says the month once. German writes 3.–5. Jan. 2026 with no spaces around its dash. Japanese does not collapse at all and repeats both full dates, joined with ~. Which fields collapse, which dash character appears, and where the spaces go are CLDR data: Intl.DateTimeFormat.prototype.formatRange reads all three, and concatenation cannot.
Numbers get the same treatment. Intl.NumberFormat.prototype.formatRange produces 2–4 in English, and for currency it decides per locale whether the symbol repeats: en-US writes €2.50 – €4.99, while de-DE writes 2,50–4,99 € (one symbol, trailing). When both endpoints format to the same string, the range collapses to a single approximate value: formatRange(3, 3) is ~3, because "3–3" is not a range any human would write.
Ranges also need one thing engineers rarely budget for: a plural form. "2–4 items" has to pick an arm, and languages disagree about which endpoint decides. Intl.PluralRules.prototype.selectRange answers per locale. English files the range 0–1 under other ("0–1 items") even though 1 alone is one, while French files the same range under one. Pick the form of the end value and one of those markets reads a wrong string.
en-US Jan 3 – 5, 2026
en-GB 3 – 5 Jan 2026
de-DE 3.–5. Jan. 2026
ja-JP 2026/01/03~2026/01/05The demo
Try these, in order
Each step reproduces one specific failure in the demo below.
- 1With the default items, compare the en-US and de-DE rows in the results grid. English (long style) writes “apples, oranges, pears, and bananas”, Oxford comma included. German writes “… und …” with no comma before it. Neither is a shareable template.
- 2Switch the Scenario to “Unit · 'A B C' (use for adjacent units, e.g. '1h 20m')” with Style “narrow”. Items join with spaces and no conjunction: the correct mode for adjacent measurements like “1 h 20 min”. Using conjunction style for units reads wrong everywhere.
- 3In the date-range section, keep the default Start date (2026-01-03) and End date (2026-01-05) and compare the en-US and ja-JP rows. Then move End date into March (2026-03-05). en-US collapses the shared fields to “Jan 3 – 5, 2026”, while ja-JP repeats both full dates: 2026/01/03~2026/01/05. With the end date in March, en-US names both months (“Jan 3 – Mar 5, 2026”). The formatter decides field by field what to repeat.
- 4Set Range start to 0 and Range end to 1, then read the selectRange table. English gives other for the range 0–1 even though select(1) is one. French gives one for the same range.
- 5Set Range start and Range end both to 3, with Format as “Plain number”. The number-range rows collapse to ~3: when both endpoints format identically, the formatter emits a single approximate value instead of a degenerate “3–3”.
- 6In the durations section, change Hours, Minutes, and the Duration style select. If the browser ships Intl.DurationFormat, each locale spells the duration in four styles. Internally it joins the units with the same unit-list patterns ListFormat exposes. If not, every row reads “(unsupported in this browser)”: the demo feature-detects instead of calling it blind.
Conjunction · 'A, B, and C' · style: long
English keeps the Oxford comma in the "long" style, Chinese joins with 、 (the ideographic comma), and Arabic places و before the last item.
Date ranges · formatRange
Intl.DateTimeFormat.prototype.formatRange collapses the fields the two dates share, per locale. Formatting each date separately and joining them with a dash repeats what the locale would have dropped.
With the default dates, en-US collapses the shared month and year ("Jan 3 – 5, 2026"). ja-JP repeats both full dates. The fields, the dash, and the spacing all come from CLDR.
Number ranges · formatRange
"2–4 items", "€2.50 – €4.99": Intl.NumberFormat.prototype.formatRange picks the locale's range pattern and decides per locale whether the currency symbol repeats. If both endpoints format to the same string, the output collapses to a single approximate value.
Which plural form does a range take? · selectRange
"2–4 items" needs a plural form too, and languages disagree on which endpoint decides it. Intl.PluralRules.prototype.selectRange answers per locale. The table reuses the Range start / Range end values above.
| Locale | select(2) | select(4) | selectRange(2, 4) |
|---|---|---|---|
| en | other | other | other |
| fr | other | other | other |
| de | other | other | other |
| pl | few | few | few |
| ru | few | few | few |
| ar | two | few | few |
| ja | other | other | other |
Try 0 and 1: English files the range 0–1 under other ("0–1 items"), even though 1 alone is one. French files the same range under one. Code that picks the plural form of the end value gets one of those markets wrong.
Durations · Intl.DurationFormat
Intl.DurationFormat formats a duration record (hours, minutes, and so on) per locale, in four styles. It is newer than the rest of this page, so the demo feature-detects it.
When to reach for which
- •
Intl.ListFormat: joining items with "and" / "or". Never writearr.join(", ")in user-facing strings. - •
DateTimeFormat.formatRange/NumberFormat.formatRange: any two endpoints shown together (dates, prices, counts). The dash and the collapsing are locale data. - •
PluralRules.selectRange: the plural form for "2–4 items". - •
Intl.DurationFormat: "1 hr, 20 min". Older engines lack it, so feature-detect before use. - • Relative time ("3 days ago") lives in the Formatters demo:
Intl.RelativeTimeFormat.
The short version
Joining values into a sentence is grammar work: Intl.ListFormat carries each locale's connectives, formatRange decides how much of a range to repeat, and selectRange answers which plural form a range takes. None of it survives a hardcoded dash or comma.
What to do about it
- Join arrays with
Intl.ListFormat, choosingtype: "conjunction" | "disjunction" | "unit"per case;unitis the mode for adjacent measurements like "1 h 20 min". - Render date spans with
Intl.DateTimeFormat.prototype.formatRange. Which shared fields collapse, which dash appears, and where the spaces go are locale data. - Render numeric and price spans with
Intl.NumberFormat.prototype.formatRange; it decides per locale whether the currency symbol repeats, and collapses equal endpoints to a single approximate value (~3). - Pick the plural form of a range with
Intl.PluralRules.prototype.selectRange, not the category of either endpoint. - Feature-detect
Intl.DurationFormatbefore calling it; it is not in every engine.
Where this comes up
Who it concerns
Moments
- ·Building any list, summary, or activity feed
- ·Rendering date, price, or quantity ranges
- ·Component library review
Field note
In O'Connor v. Oakhurst Dairy, the First Circuit ruled in 2017 that a missing serial comma in a Maine overtime law left an exemption clause ambiguous. The drivers settled in 2018 for about $5 million in unpaid overtime.