A deep dive into ECMA-402's BasicFormatMatcher algorithm and Unicode CLDR locale data.
console.log(
new Date().toLocaleString("en-GB", {
year: "numeric",
month: "numeric",
day: "numeric",
}),
);This yields a 2-digit (zero-padded) month in en-GB, while the en-US equivalent does not.
const d = new Date(2026, 0, 5); // Jan 5
console.log('en-GB:', d.toLocaleString('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' }));
// "05/01/2026"
console.log('en-US:', d.toLocaleString('en-US', { year: 'numeric', month: 'numeric', day: 'numeric' }));
// "1/5/2026"resolvedOptions() reveals what's going on (in V8/Node/Chrome):
const gb = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' });
const us = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'numeric', day: 'numeric' });
console.log(gb.resolvedOptions());
// { ..., month: "2-digit", day: "2-digit", year: "numeric" }
console.log(us.resolvedOptions());
// { ..., month: "numeric", day: "numeric", year: "numeric" }- en-US:
month: "numeric"stays as"numeric"→ produces1(no leading zero) - en-GB:
month: "numeric"gets resolved to"2-digit"→ produces01(leading zero)
There are two layers:
The spec (ECMA-402 §11.5.1 "BasicFormatMatcher") says that when you request "numeric", the implementation finds the best fit date pattern from the locale's available formats. It treats "numeric" and "2-digit" as close matches (penalty of just 6) rather than exact requirements. So if the locale's best-matching pattern uses dd/MM/yyyy (2-digit), that's what you get — your "numeric" request gets overridden.
The actual patterns come from CLDR:
- en-GB: The short date pattern is
dd/MM/y— note theddandMM(2-digit, zero-padded) - en-US: The short date pattern is
M/d/y— note the singleMandd(no padding)
In CLDR's date field notation:
M= numeric month, no padding (→"numeric")MM= numeric month, zero-padded to 2 digits (→"2-digit")dvsdd= same for day
Interestingly, Firefox (SpiderMonkey) and V8 (Node/Chrome) disagree on what resolvedOptions() reports:
// Firefox:
new Intl.DateTimeFormat('en-GB', { month: 'numeric' }).resolvedOptions().month;
// "numeric"
// V8/Node/Chrome:
new Intl.DateTimeFormat('en-GB', { month: 'numeric' }).resolvedOptions().month;
// "2-digit"Both produce the same zero-padded output for en-GB, but:
- V8: Looks at the actual ICU pattern it resolved to (
dd/MM/y), seesMM, and reports"2-digit". More honest — tells you what will actually be rendered. - Firefox: Reports back your original request (
"numeric"), even though the output will be zero-padded.
The spec says in §11.5.4 that resolvedOptions() should return the value from the internal slot (e.g., [[Month]]), but it's not crystal clear whether that slot should hold the originally-requested value or the resolved/matched value. There's been TC39/ECMA-402 discussion about this exact ambiguity.
No, not with toLocaleString() / Intl.DateTimeFormat alone. The locale's CLDR pattern wins.
const parts = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(new Date(2026, 0, 5));
// Strip leading zeros from numeric parts
const result = parts
.map((p) =>
p.type === "month" || p.type === "day"
? String(Number(p.value))
: p.value,
)
.join("");
console.log(result); // "5/1/2026"This preserves the en-GB ordering (day/month/year) and separators, but strips the padding.
There's no padding: false or month: "numeric-no-pad" option in the spec. The "numeric" vs "2-digit" distinction is really just a hint to the best-fit matcher. The Intl API's philosophy is fundamentally "the locale knows best."
options: What you asked for — e.g.{ year: "numeric", month: "numeric", day: "numeric" }formats: A list of available date format patterns for en-GB, sourced from CLDR/ICU. These are precomputed patterns like:{ month: "2-digit", day: "2-digit", year: "numeric" }(from CLDR patterndd/MM/y){ month: "long", day: "numeric", year: "numeric" }(fromd MMMM y){ month: "short", day: "numeric", year: "numeric" }(fromd MMM y)- etc.
Critically, en-GB does not have a format with month: "numeric" (single M). Its numeric date pattern is dd/MM/y, which maps to month: "2-digit". In contrast, en-US has M/d/y, which maps to month: "numeric".
For each candidate format, for each date/time property (day, month, year, etc.), it compares what you asked for vs. what the candidate offers:
| Situation | Penalty |
|---|---|
| You asked for a field, candidate doesn't have it | -120 (removalPenalty) |
| You didn't ask for a field, candidate has it | -20 (additionPenalty) |
| Values differ | -3 to -8 depending on direction (see below) |
| Values match | 0 |
When optionsProp ≠ formatProp, it uses this ordered list:
values = [ "2-digit", "numeric", "narrow", "short", "long" ]
0 1 2 3 4
It computes:
delta = clamp(formatPropIndex - optionsPropIndex, -2, 2)
| delta | Meaning | Penalty |
|---|---|---|
| +2 | Candidate is much "longer" than requested | -6 (longMorePenalty) |
| +1 | Candidate is slightly "longer" | -3 (shortMorePenalty) |
| -1 | Candidate is slightly "shorter" | -6 (shortLessPenalty) |
| -2 | Candidate is much "shorter" | -8 (longLessPenalty) |
You asked: month: "numeric" → index 1
Best candidate offers: month: "2-digit" → index 0
delta = 0 - 1 = -1 → shortLessPenalty = 6
So the en-GB candidate dd/MM/y only gets a -6 penalty for month and -6 for day = -12 total. This is a small penalty — the algorithm considers "2-digit" and "numeric" to be close neighbors. The dd/MM/y pattern is the best available numeric date format for en-GB, so it wins.
Once dd/MM/y wins as the best format, the [[Month]] internal slot gets set to "2-digit" (from the winning candidate), not your original "numeric". That's why V8's resolvedOptions() reports "2-digit" — it's telling you what the winning pattern actually uses.
Conversation from 2026-03-15. Explored using Node.js and the ECMA-402 spec.