Skip to content

Instantly share code, notes, and snippets.

@llimllib
Created March 15, 2026 19:18
Show Gist options
  • Select an option

  • Save llimllib/c4304abde8392fe8eff75577008a19e1 to your computer and use it in GitHub Desktop.

Select an option

Save llimllib/c4304abde8392fe8eff75577008a19e1 to your computer and use it in GitHub Desktop.
Why Intl.DateTimeFormat with month: 'numeric' produces zero-padded months in en-GB but not en-US — a deep dive into ECMA-402's BasicFormatMatcher and CLDR locale data

Why Intl.DateTimeFormat with month: "numeric" produces zero-padded months in en-GB but not en-US

A deep dive into ECMA-402's BasicFormatMatcher algorithm and Unicode CLDR locale data.


The observation

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.

What's happening under the hood

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" → produces 1 (no leading zero)
  • en-GB: month: "numeric" gets resolved to "2-digit" → produces 01 (leading zero)

Where is this specified?

There are two layers:

1. ECMA-402 spec (the algorithm)

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.

2. Unicode CLDR (the data)

The actual patterns come from CLDR:

In CLDR's date field notation:

  • M = numeric month, no padding (→ "numeric")
  • MM = numeric month, zero-padded to 2 digits (→ "2-digit")
  • d vs dd = same for day

Firefox vs V8 disagreement

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), sees MM, 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.

Can you force unpadded numeric months in en-GB?

No, not with toLocaleString() / Intl.DateTimeFormat alone. The locale's CLDR pattern wins.

Workaround: formatToParts()

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."

How BasicFormatMatcher works, step by step

The inputs

  1. options: What you asked for — e.g. { year: "numeric", month: "numeric", day: "numeric" }
  2. 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 pattern dd/MM/y)
    • { month: "long", day: "numeric", year: "numeric" } (from d MMMM y)
    • { month: "short", day: "numeric", year: "numeric" } (from d 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".

The scoring loop

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

The mismatch penalty for differing values

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)

Applied to our case

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.

What happens after matching

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment