ICU number and date skeletons in messages

A price is formatted in the component and interpolated into a message as a string. The German translator needs the currency after the number and cannot move it, because by the time the message is assembled the value is already €1,234.50. A plural message counting items stops selecting the right branch, because the count arrived as text.

ICU MessageFormat can format numbers and dates itself, using a compact syntax called a skeleton. Moving the formatting inside the message keeps the locale decision in the layer that knows the locale.

Five skeletons and their locale-specific output A currency skeleton renders with the symbol before the number in English and after it in German. A compact skeleton uses a different abbreviation word in each language. A two-decimal skeleton swaps the group and decimal separators. A percent skeleton differs in spacing. A year-month-day skeleton reorders the components entirely. A skeleton is a request, not a pattern Skeleton Renders in en-US Renders in de-DE currency/EUR €1,234.50 1.234,50 € symbol moves compact-short 1.2K 1,2 Tsd. unit word differs .00 1,234.50 1.234,50 separators swap percent scale/100 12% 12 % spacing differs yMMMd Jan 5, 2026 05.01.2026 order differs
The skeleton says what you want shown. Every one of these differences is CLDR deciding how.

Root cause: a pre-formatted value is opaque to the message

An ICU message is evaluated against values. When one of those values is already a formatted string, three properties are lost at once.

The number is gone. A plural argument selects a branch using the numeric value, so passing "1,234" means the selection happens against a string. Some implementations coerce, some throw, and none of them are doing what the message says.

The formatting is fixed before the locale is applied. The formatter that produced the string ran wherever the component ran, with whatever locale that context had. If that is not the locale the message is rendered in — a server-rendered email, a queued notification, a report generated for another reader — the two disagree.

The translator cannot work with it. Reordering a sentence around a value is routine in translation, and a value whose formatting is decided elsewhere is a black box. Worse, the translator cannot see what will actually render, so a message that reads correctly in the catalogue can read wrongly on screen.

Pre-formatted interpolation compared with an in-message skeleton Formatting a value before interpolating it hands the message a string, so translators cannot reorder around it, plural selection can no longer see the number, and two locales can drift apart. Expressing the format as a skeleton inside the message keeps the raw value and its formatting together in one translatable unit. Two ways to put a formatted value in a message Format outside, interpolate a string · The message receives text, not a number · Translators cannot reorder around it · Plural selection loses the number · Two locales can disagree with each other Skeleton inside the message · The message receives the raw value · Formatting is part of the translatable unit · Plural and number stay in one argument · One catalogue entry, one behaviour Formatting outside the message moves a locale decision out of the layer that knows the locale.
The second column is also the one where a translator can see what will be rendered.

Minimal reproducible example

// Formatting outside the message
const price = new Intl.NumberFormat(locale, {
  style: 'currency', currency: 'EUR',
}).format(amount);

t('cart.total', { price });      // message: "Total: {price}"
{ "cart.total": "Total: {price}" }

The German catalogue entry can only place the opaque {price} somewhere in the sentence. It cannot influence whether the symbol precedes or follows, and a plural message elsewhere receiving the same pre-formatted value cannot count with it.

The fix: express the format as a skeleton in the message

{
  "cart.total": "Total: {amount, number, ::currency/EUR}",
  "cart.items": "{count, plural, one {# item} other {# items}} · {subtotal, number, ::currency/EUR .00}",
  "order.placed": "Placed on {placedAt, date, ::yMMMd}"
}
t('cart.total', { amount });          // a number, not a string
t('order.placed', { placedAt });      // a Date, not a string

The message now receives raw values. The skeleton says what should be shown — a currency amount in euros, a date with a year, an abbreviated month and a day — and the runtime resolves how to show it from CLDR data for the rendering locale.

The parts of a skeleton argument A skeleton argument names the value, declares the type as number, introduces the skeleton with a double colon, and lists space-separated stems such as a currency selection and a fraction-digit specification, which are independent of one another and may be written in any order. Reading a skeleton argument {amount, number, ::currency/EUR .00} ^ argument name ^ type: number ^ :: introduces a skeleton ^ stems, space separated The :: prefix Marks a skeleton, not a legacy style Stems Independent of one another Order-free Stems may appear in any order
Three things to recognise, and the rest is a vocabulary of stems you look up when you need them.

The stems worth knowing

Skeleton syntax is a small vocabulary, and a handful of stems cover nearly every product need.

currency/XXX formats as money in the named ISO currency, taking the fraction digits from the currency rather than from your assumption — the behaviour described in Intl.NumberFormat currency rounding mismatch.

.00 and .## fix or cap fraction digits. A trailing /w suppresses trailing zeros where you want a compact look.

compact-short and compact-long render 1200 as 1.2K or 1.2 thousand, with the abbreviation word coming from the locale rather than from your string table.

percent scale/100 turns a fraction into a percentage with locale-correct spacing, which differs between languages more often than people expect.

unit/kilometer-per-hour formats a measurement with the locale’s unit conventions, including whether the unit is abbreviated and where it sits.

Date skeletons are letter patterns describing which fields to include — yMMMd, Hm, EEEEd — and never a literal pattern. That distinction is the whole point: MM/dd/yyyy is one country’s order, whereas yMd asks for a numeric year, month and day and lets each locale order them.

What skeletons do not solve

Two limitations are worth stating so the technique is not oversold.

Time zones still need to be explicit. A date skeleton says which fields to render, not which zone to render them in, so a message formatting a timestamp needs the zone supplied to the formatter — the failure covered in Intl.DateTimeFormat timezone and DST bugs.

Skeleton support varies by library and version. The double-colon syntax is the modern form and older runtimes accept only the legacy style arguments. Where a catalogue must work across both, the legacy forms remain available, and mixing the two in one catalogue is worth avoiding since a translator cannot tell which is which.

Migrating a catalogue away from pre-formatted values

Moving formatting into messages touches both the catalogue and the call sites, and the two have to move together — which makes it a good candidate for doing one namespace at a time rather than all at once.

The shape of the change is mechanical. A call site that formats and passes a string becomes one that passes the raw value; the catalogue entry gains a skeleton on the corresponding argument. Because the argument name does not change, the message identity is unchanged and no translation is lost — the property that matters most, since a rename would archive the existing translations.

What does change is the rendered output, sometimes subtly. A value previously formatted with explicit options may render slightly differently under a skeleton — a different number of fraction digits, a different compact form, a space before a percent sign. Those differences are usually corrections rather than regressions, and they are still worth catching, which is what makes a snapshot of rendered messages per locale a useful thing to have before starting.

There is one category that should not be migrated: values whose formatting genuinely is not a locale decision. An identifier that happens to be numeric, a version string, a fixed-width code — those are text that must never be grouped or localized, and passing them as numbers invites exactly that. Marking them clearly, as plain string arguments with a comment, prevents a well-meaning later change from applying a skeleton to an order number and rendering it as 1,234,567.

That last failure is common enough to be worth naming in a contributing guide, because it looks like an improvement in review and is a customer-visible bug on a receipt.

Verification

test.each([
  ['en-US', '$1,234.50'],
  ['de-DE', '1.234,50 €'],
  ['ja-JP', '€1,234.50'],
])('%s renders the total as %s', (locale, expected) => {
  const msg = new IntlMessageFormat(
    'Total: {amount, number, ::currency/EUR}', locale
  );
  expect(msg.format({ amount: 1234.5 })).toBe(`Total: ${expected}`);
});

test('a plural still selects on the number', () => {
  const msg = new IntlMessageFormat(
    '{count, plural, one {# item} other {# items}}', 'en'
  );
  expect(msg.format({ count: 1 })).toBe('1 item');
});

The Japanese row is worth keeping: it shows the euro rendered by a locale that is neither the currency’s nor English, which is the case that catches formatting done in the wrong place.

When to escalate

If a skeleton renders literally in the output, the runtime is parsing it as a legacy style argument — either the library predates skeleton support or the double colon was omitted.

If numbers render correctly and dates do not, the date data may be missing from a partial ICU build, which is the gap described in Intl polyfills and ICU data loading.

If a translator asks what a skeleton will produce, that is a tooling gap rather than a message problem. Rendering a sample value beside each message in the translation interface answers it, and it is worth asking your translation system whether it can.

FAQ

Should every number in a message use a skeleton?

Every number whose rendering is locale-dependent, which is most of them. A bare {count} renders the number with default formatting, which is fine for a small integer and wrong for anything above a thousand, where grouping matters.

Can a skeleton be chosen at runtime?

Not within a message — the skeleton is part of the message text, which is the point. Where the format genuinely varies by data, such as a currency that differs per row, the currency is passed as an argument to a formatter and the message receives the value with its own currency stem selected by a select branch.

Do skeletons work in every framework?

They work wherever the ICU message library supports them, which includes the current versions of the major JavaScript implementations. Frameworks with their own message syntax may not, and in those the equivalent is a formatter registered with the i18n instance rather than an inline skeleton.

Does this affect extraction?

No, and that is a benefit: a message containing a skeleton is one string with one identifier, so nothing about extraction, translation memory or the CI gates changes — the same properties described in string catalog governance.

Part of ICU Message Format Deep Dive.