ECMA-402 and Temporal: The Other Specification, and the Hardest Data Type
Locale-sensitive behaviour is a second standard with implementation-defined data, and time is a domain where the 1995 design cannot be patched.
ECMA-262 defines JavaScript's semantics completely except for one deliberate hole: anything depending on human language or region is delegated to ECMA-402, the Internationalization API, whose results are shaped by locale data the specification does not fix. This chapter explains what that means for determinism and testing, reads the `Intl` objects as a protocol over CLDR and ICU, and then turns to `Date`, why it is unrepairable, and how `Temporal` (Stage 3, shipping) rebuilds the domain from exact types.
In this chapter
Two specifications and one deliberate hole
Every algorithm in ECMA-262 is deterministic given its inputs, with one class of exceptions: operations whose result depends on a human convention. String.prototype.toLocaleUpperCase, Number.prototype.toLocaleString, Date.prototype.toLocaleDateString, String.prototype.localeCompare, and Array.prototype.toLocaleString are defined in ECMA-262 only as "implementation-defined" unless the host implements ECMA-402, in which case ECMA-402 overrides them with precise algorithms over an imprecise input: the implementation's locale data. ECMA-402 specifies the API (Intl.NumberFormat, Intl.DateTimeFormat, Intl.Collator, Intl.PluralRules, Intl.RelativeTimeFormat, Intl.ListFormat, Intl.Segmenter, Intl.DisplayNames, Intl.Locale, Intl.DurationFormat), the resolution of options and locales (BCP 47 tags, the LookupMatcher and BestFitMatcher algorithms, the -u- Unicode extension keywords for numbering systems and calendars), and the structure of results (formatToParts returns typed parts), but it explicitly does not specify which languages are supported or what the strings look like. Two conforming engines may format the same date differently, and a single engine may format it differently after an ICU update. Tests that assert on the output of toLocaleString are therefore asserting on CLDR, not on JavaScript.
const n = 1234567.891;
console.log("en-US:", n.toLocaleString("en-US"), "| de-DE:", n.toLocaleString("de-DE"), "| en-IN:", n.toLocaleString("en-IN"), "| ar-EG:", n.toLocaleString("ar-EG"));
// The spec fixes the *parts* and their types; the literal separators come from locale data.
const parts = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).formatToParts(n);
console.log("formatToParts types:", parts.map((p) => p.type).join(" "));
console.log("formatted:", parts.map((p) => p.value).join(""));
// Locale negotiation is specified: unsupported tags fall back, and resolvedOptions reports what was chosen.
const rf = new Intl.DateTimeFormat("xx-ZZ", { month: "long" });
console.log("resolvedOptions locale:", rf.resolvedOptions().locale, "(fallback for an unknown tag is the default locale)");
// Unicode extension keywords select a numbering system or calendar inside the tag itself.
console.log("Hindi with Devanagari digits:", n.toLocaleString("hi-IN-u-nu-deva"), "| Japanese imperial calendar:", new Date(2026, 8, 4).toLocaleDateString("ja-JP-u-ca-japanese"));
console.log("Segmenter word boundaries in Thai (no spaces):", [...new Intl.Segmenter("th", { granularity: "word" }).segment("สวัสดีชาวโลก")].filter((s) => s.isWordLike).map((s) => s.segment).join(" | "));
console.log("plural category of 1, 2, 5 in Polish:", [1, 2, 5].map((k) => new Intl.PluralRules("pl").select(k)).join(", "));Which of these lines differ between engines depends on their ICU versions and locale coverage; that variability is the point, and the multi-engine recording for this probe shows it directly.
Comparison is not code-point order
"a" < "B" is false because relational comparison of Strings is defined in ECMA-262 as code-unit order (IsLessThan step 3), and B (U+0042) precedes a (U+0061). Array.prototype.sort without a comparator uses the same order, after converting elements to strings. Human ordering needs Intl.Collator, which implements the Unicode Collation Algorithm (UTS #10) with CLDR tailorings: ä sorts with a in German and after z in Swedish; numeric: true orders "file10" after "file2"; sensitivity: "base" treats a, A, and á as equal. A collator is also the correct tool for equality of user-visible text, because Unicode normalisation (String.prototype.normalize) only unifies canonically equivalent sequences (é as one code point versus e + combining acute) and not compatibility or case differences.
console.log("'a' < 'B' by code units:", "a" < "B", "| default sort:", ["b", "a", "B", "A", "ä"].sort().join(""));
console.log("German collation:", ["b", "a", "B", "A", "ä"].sort(new Intl.Collator("de").compare).join(""), "| Swedish:", ["b", "a", "B", "A", "ä", "z"].sort(new Intl.Collator("sv").compare).join(""));
const files = ["file10", "file2", "File1"];
console.log("numeric + base sensitivity:", files.sort(new Intl.Collator("en", { numeric: true, sensitivity: "base" }).compare).join(", "));
const composed = "é", decomposed = "e\u0301";
console.log("=== on canonically equivalent strings:", composed === decomposed, "| lengths:", composed.length, decomposed.length);
console.log("canonically equivalent after normalize:", composed.normalize("NFC") === decomposed.normalize("NFC"));
console.log("collator sees them equal even without normalize:", new Intl.Collator("en").compare(composed, decomposed) === 0);
console.log("compatibility equivalence needs NFKC: fi vs fi:", "fi".normalize("NFC") === "fi", "|", "fi".normalize("NFKC") === "fi");`Date`: one number, three misconceptions
A Date object holds a single Number, the time value: milliseconds since the Unix epoch in UTC, or NaN. That is the whole state. It has no time zone (methods like getHours apply the host's current zone at call time, and getTimezoneOffset can differ between two Dates in the same program across a DST boundary), no calendar (proleptic Gregorian only), no notion of a date without a time or a time without a date, and one-millisecond resolution. Its constructor's string parsing is specified only for the ISO format (Date.parse of anything else is implementation-defined, so new Date("2026-09-04") is UTC midnight while new Date("2026/09/04") is local midnight in most engines), months are zero-based because java.util.Date's were, and every setter mutates in place, so a Date handed to a function may come back changed. None of this can be fixed: Annex B-level compatibility (chapter 14) freezes Date as it is. The escape route is a new type family, which is what Temporal is.
const d = new Date(Date.UTC(2026, 8, 4, 12, 0, 0));
console.log("state is one number:", d.getTime(), "| valueOf:", +d, "| JSON:", JSON.stringify(d));
console.log("host time zone applied at call time:", Intl.DateTimeFormat().resolvedOptions().timeZone, "-> getHours():", d.getHours(), "| getUTCHours():", d.getUTCHours());
console.log("ISO date-only string is UTC:", new Date("2026-09-04").toISOString(), "| with a time and no zone it is local: getHours() =", new Date("2026-09-04T00:00").getHours());
console.log("months are zero-based:", new Date(2026, 8, 4).getMonth(), "| day-of-month overflows roll over:", new Date(2026, 1, 30).toDateString());
console.log("Invalid Date is a NaN time value:", new Date("nonsense").getTime(), "| isNaN(date) works:", isNaN(new Date("nonsense")));
const shared = new Date(2026, 0, 31);
shared.setMonth(1); // Jan 31 -> Feb 31 -> rolls to March 3
console.log("setters mutate and overflow:", shared.toDateString());
console.log("DST: offsets differ within one year for this zone?", new Set([0, 3, 6, 9].map((m) => new Date(2026, m, 1).getTimezoneOffset())).size > 1);Temporal: exact types for a domain with many of them
Temporal (ECMA-262 Stage 3; shipping in Firefox since 139 and in Chromium and Safari behind flags or in preview as of 2025–2026) replaces one type with a family, each representing exactly one thing: `Temporal.Instant`, a point on the timeline as nanoseconds since the epoch, with no calendar or zone; `Temporal.ZonedDateTime`, an instant paired with an IANA time zone and a calendar, which is the only type that can answer "what time is it in Berlin"; `Temporal.PlainDate`, `PlainTime`, `PlainDateTime`, `PlainYearMonth`, and `PlainMonthDay`, wall-clock values with no zone at all, for birthdays, opening hours, and recurring events; and `Temporal.Duration`, with separate calendar units (years, months, weeks, days) and exact units (hours down to nanoseconds), because a month is not a fixed number of days and adding one requires a calendar and a reference date. Every object is immutable; arithmetic returns new values and requires you to state the disambiguation policy for gaps and overlaps at DST transitions (disambiguation: "compatible" | "earlier" | "later" | "reject") instead of guessing. Parsing accepts only ISO 8601 / RFC 9557 strings (2026-09-04T12:00:00+02:00[Europe/Berlin][u-ca=iso8601]), so there is no implementation-defined Date.parse corner. Non-Gregorian calendars are first-class through ECMA-402's calendar identifiers. The design is java.time's, adopted after two decades of evidence that separating these concepts is what prevents the bugs Date invites.
if (typeof Temporal === "object") {
const zdt = Temporal.ZonedDateTime.from("2026-03-29T01:30:00[Europe/Berlin]"); // 30 minutes before the DST gap
console.log("ZonedDateTime carries its zone:", zdt.toString());
console.log("add 1 hour across the gap (exact):", zdt.add({ hours: 1 }).toString());
console.log("PlainDate arithmetic is calendar-aware:", Temporal.PlainDate.from("2026-01-31").add({ months: 1 }).toString(), "(clamped to Feb 28, not rolled to March)");
const dur = Temporal.Duration.from({ months: 1, days: 3, hours: 5 });
console.log("Duration keeps calendar and exact units apart:", dur.toString(), "| total hours needs a reference date:", dur.total({ unit: "hours", relativeTo: "2026-02-01" }));
console.log("Instant has no zone:", Temporal.Now.instant().toString().slice(0, 19) + "…", "| epochNanoseconds is a BigInt:", typeof Temporal.Now.instant().epochNanoseconds);
console.log("Non-Gregorian calendar:", Temporal.PlainDate.from("2026-09-04").withCalendar("hebrew").toString());
try { Temporal.PlainDateTime.from("2026-03-29T02:30").toZonedDateTime("Europe/Berlin", { disambiguation: "reject" }); } catch (e) { console.log("a wall-clock time in the DST gap can be rejected explicitly:", e.constructor.name); }
} else {
console.log("Temporal is not in this engine yet (Stage 3; shipped in Firefox 139+, in preview elsewhere). The polyfill @js-temporal/polyfill implements the same API.");
console.log("The Date equivalents of the lines above are either impossible (no zone, no calendar) or wrong:");
console.log(" Jan 31 + 1 month with Date:", (() => { const x = new Date(2026, 0, 31); x.setMonth(1); return x.toDateString(); })(), " <- rolled over");
}What this means for tests, snapshots, and servers
- Pin the locale and time zone in tests.
TZ=UTCand explicit"en-US"arguments makeDateandIntloutput reproducible on a machine; they do not make it reproducible across ICU versions. Snapshot tests of formatted strings will break on Node and browser upgrades, and that is CLDR changing, not a regression. - Use `formatToParts` and `resolvedOptions` when the structure matters. They are specified; concatenated strings are not.
- Never parse with `new Date(string)` outside the ISO format. Use
Temporalor an explicit parser;Date.parseof anything else is implementation-defined by the specification. - Store instants, display zoned times. Persist
Date.getTime()/Temporal.Instant, and attach a named IANA zone only when rendering or when a wall-clock time is the actual business rule (a recurring 9 a.m. meeting is aPlainTimeplus a zone, not an instant). - Engines ship different ICU sizes. Node's
small-icubuilds support onlyen; a server that formats forde-DEmay silently fall back.Intl.DateTimeFormat.supportedLocalesOfandresolvedOptions().localetell you what you actually got.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Locale data as a shared repository | Unicode Consortium, Common Locale Data Repository (CLDR) (2003) | ECMA-402 requires implementations to consult locale data but leaves its content to them; every engine ships ICU built on CLDR, so results agree across browsers to the extent CLDR versions do. |
| Unicode text segmentation | Mark Davis (Unicode Technical Committee), UAX #29: Unicode Text Segmentation (2005) | `Intl.Segmenter` implements UAX #29 grapheme, word, and sentence boundaries: the level above code points that chapter 2 stopped at. |
| Calendar and time-zone representation | Paul Eggert and the tz database maintainers; the ISO 8601 committee, IANA Time Zone Database (1986–); ISO 8601 (1988) (1988) | Temporal's `ZonedDateTime` names IANA zones and serialises with ISO 8601 plus RFC 9557 extensions; `Date` knows neither. |
| Separating instants, wall-clock times, and durations as distinct types | Stephen Colebourne, Joda-Time (2002) and JSR-310 java.time (2014) (2014) | Temporal's type family (Instant, PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Duration) is java.time's design, adopted after `Date`, itself a copy of java.util.Date, proved unfixable. |
Primary sources
- ECMA-402: ECMAScript Internationalization API Specification
- ECMA-262, §21.4 Date Objects (time values, Date.parse)
- ECMA-262, §7.2.13 IsLessThan (string comparison by code units)
- TC39 proposal: Temporal (Stage 3) and its documentation
- Unicode CLDR: Common Locale Data Repository
- UTS #10: Unicode Collation Algorithm
- UAX #29: Unicode Text Segmentation
- RFC 9557: Date and Time on the Internet: Timestamps with Additional Information
- Node.js: Internationalization support (ICU build options)