JS Internals
Chapter 19SpecHostEngine4 runnable probes

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
  1. Two specifications and one deliberate hole
  2. Comparison is not code-point order
  3. `Date`: one number, three misconceptions
  4. Temporal: exact types for a domain with many of them
  5. What this means for tests, snapshots, and servers

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.

ProbeConformanceStructure is specified; strings are data
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.

ProbeConformanceCode-unit order, normalised equality, and collation
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.

ProbeConformanceThe one-number model and its consequences
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.

ProbeTemporal where available, with the equivalent Date pain shown alongside
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=UTC and explicit "en-US" arguments make Date and Intl output 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 Temporal or an explicit parser; Date.parse of 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 a PlainTime plus a zone, not an instant).
  • Engines ship different ICU sizes. Node's small-icu builds support only en; a server that formats for de-DE may silently fall back. Intl.DateTimeFormat.supportedLocalesOf and resolvedOptions().locale tell you what you actually got.
Origin

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.

MechanismOriginPath into JavaScript
Locale data as a shared repositoryUnicode 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 segmentationMark 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 representationPaul 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 typesStephen 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