ICU Message Format Deep Dive
ICU MessageFormat lets one translatable string carry plural, select, and ordinal logic plus typed number, date, and time arguments — so when a German plural renders “1 Ergebnisse” or a nested select throws Expected "," but "}" found, the cause is almost always a mismatched brace, a missing other arm, or an un-escaped literal. This guide treats an ICU string as a small grammar you parse, not a template you interpolate, and shows how to author, compile, and verify each construct against the spec.
ICU MessageFormat is part of the Core i18n Architecture & Locale Negotiation layer: the resolved locale chooses which translation bundle loads, and that bundle’s ICU strings are then evaluated against locale-specific CLDR plural data. Get the syntax wrong and the failure surfaces only in the locale that exercises the broken arm — usually in production, in a language nobody on the team reads.
Prerequisites
Concept & spec — what ICU MessageFormat actually is
ICU MessageFormat is a grammar defined by the ICU project and aligned with Unicode TR35 (LDML), which specifies the CLDR plural and ordinal rule data the formatter consults. A message is a sequence of literal text and arguments wrapped in braces. An argument has a name and, optionally, a type (number, date, time, plural, select, selectordinal) and a style or set of cases. The formatter parses the whole string into an argument tree, then walks that tree against the active locale’s CLDR data to pick the right branch and format each leaf.
This matters because the locale chosen by the Core i18n Architecture & Locale Negotiation layer does more than select a file — it changes the set of plural categories available. English exposes only one and other; Polish exposes one, few, many, and other; Arabic exposes all six (zero, one, two, few, many, other). A string authored against English categories will silently fall through to other in those languages, which is grammatically wrong, not merely a formatting nuisance. The deeper category model is covered in pluralization rules across languages.
The four constructs you will use daily:
plural— branches on a numeric value’s CLDR plural cardinal category, plus optional exact matches like=0. Inside a branch,#prints the value formatted for the locale.selectordinal— same shape aspluralbut uses CLDR ordinal categories (“1st”, “2nd”, “3rd”), which are a different rule set entirely.select— branches on an arbitrary string key you supply (e.g.gender,role). It is not locale-aware; you define the keys.- Typed arguments —
{price, number, ::currency/EUR},{ts, date, long},{ts, time, short}delegate toIntl.NumberFormat/Intl.DateTimeFormatunder the locale.
A subtlety that trips teams up: select and plural look syntactically identical but behave fundamentally differently. plural consults locale data to decide which arm matches — you do not control the category names, CLDR does. select matches on the literal string you pass, so its arm names are part of your application contract and must be stable across every translated catalog. Translators editing a select message must never rename admin to Administrator; doing so silently routes every admin to the other arm. Treat select keys as enum values, not copy.
The same brace-counting discipline that the parser applies is the discipline you need while authoring. Each argument opens with {, names the variable, optionally adds a comma and a type, and closes with a matching }. The single most productive habit is to format multi-line messages with consistent indentation so that every opening brace visibly aligns with its close — most “Expected , but } found” errors are a brace that closed one level too early.
{count, plural,
=0 {No results}
one {One result}
other {# results}
}
Step-by-step implementation
1. Author a plural message with an explicit other
Every plural and selectordinal MUST contain an other arm — the spec requires it as the universal fallback, and most parsers throw without it. Use =N exact matches for special-cased copy like “No results”; they are checked before category matching.
{count, plural,
=0 {Your cart is empty}
one {{count} item in your cart}
other {{count} items in your cart}
}
2. Add a select for non-numeric branching
select keys are literal strings you pass in. It too requires an other arm. Keep keys lowercase and stable so translators and translation memory match them across locales.
{role, select,
admin {You can edit every project}
editor {You can edit assigned projects}
other {You have read-only access}
}
3. Use selectordinal for ranked copy
Ordinals use a separate CLDR rule set. In English the categories are one (1st), two (2nd), few (3rd), and other (4th, 5th…). Reaching for plural here is the single most common ordinal bug — see ICU select/ordinal vs plural pitfalls.
{place, selectordinal,
one {#st place}
two {#nd place}
few {#rd place}
other {#th place}
}
4. Format typed number, date, and time arguments
Typed arguments hand the value to ECMA-402 Intl formatters. Prefer the ::skeleton syntax for numbers — it is the modern, locale-robust form and survives translation better than fixed styles.
{price, number, ::currency/EUR}
spent on {when, date, long} at {when, time, short}
5. Nest constructs and select-inside-plural carefully
You can place a select inside a plural branch, but each # still refers to the nearest enclosing plural/selectordinal argument. Deeply nested literal braces and apostrophes are where most escaping errors originate — covered in ICU nested select message escaping errors.
{count, plural,
one {# message from {sender, select,
team {the team}
other {{senderName}}}}
other {# messages}
}
6. Compile to AST at build time
Parsing at runtime costs latency and ships the parser to the client. Pre-compile catalogs to AST with @formatjs/cli; malformed strings fail the build instead of a user’s request. The runtime then evaluates the AST directly.
npx formatjs compile-folder --ast locales/ compiled/
Configuration reference
| Option | Type | Description / default |
|---|---|---|
locale |
string (BCP-47) |
Resolved locale that drives CLDR category selection and Intl formatting. No default — pass the negotiated value. |
defaultRichTextElements |
Record<string, fn> |
Maps inline tags (e.g. <b>) to render functions; lets messages carry markup without raw HTML. |
ignoreTag |
boolean |
When true, <...> is treated as literal text, not a rich-text tag. Default false. |
formats |
object |
Named number/date/time styles reusable across messages (e.g. a formats.number.EUR preset). |
--ast (CLI) |
flag | Emit pre-parsed AST instead of raw strings, removing the runtime parser. Default off. |
requiresOtherClause (lint) |
boolean |
formatjs/no-invalid-icu-adjacent rule failing any plural/select missing other. Default true. |
onError |
fn |
Runtime hook for parse/format failures; route to a logger and render a safe fallback rather than throwing. |
Framework variants
React / Next.js (react-intl, formatjs)
intl.formatMessage parses or evaluates the AST; pass interpolation values as the second argument. The active locale comes from IntlProvider, which should receive the negotiated locale, not a literal.
import { useIntl } from 'react-intl';
function CartBadge({ count }: { count: number }) {
const intl = useIntl();
return <span>{intl.formatMessage({ id: 'cart.count' }, { count })}</span>;
}
Vue / Nuxt (vue-i18n)
vue-i18n supports ICU when the message compiler is configured for it; $t takes the named values object. Plural categories still resolve through CLDR for the component’s active locale.
// message: "cart.count": "{count, plural, one {# item} other {# items}}"
this.$t('cart.count', { count: items.length })
Angular
Angular’s built-in i18n uses ICU expressions directly in templates with the same plural/select syntax, extracted into XLIFF. The compiler validates the constructs at build time.
<span i18n>{count, plural, =0 {No items} one {One item} other {{{count}} items}}</span>
Node.js backend
On the server, instantiate IntlMessageFormat per message (or cache compiled instances) and feed it the request’s negotiated locale so server-rendered strings match the client.
import IntlMessageFormat from 'intl-messageformat';
const msg = new IntlMessageFormat(
'{count, plural, one {# result} other {# results}}',
req.locale,
);
res.send(msg.format({ count }));
Verification
Compile the catalog in CI; the compile step is itself the lint, failing on any malformed or other-less message. Then assert rendered output for the categories your languages actually use.
# Fails the build on malformed ICU or missing `other`
npx formatjs compile-folder --ast locales/ compiled/ \
&& echo "ICU catalog valid"
import IntlMessageFormat from 'intl-messageformat';
const m = new IntlMessageFormat(
'{count, plural, one {# item} other {# items}}', 'en',
);
// expected: "1 item"
console.assert(m.format({ count: 1 }) === '1 item');
// expected: "5 items"
console.assert(m.format({ count: 5 }) === '5 items');
For Polish or Arabic, assert the few/many/zero arms explicitly — an English-only test suite will never exercise them, and that is exactly how category gaps reach production. A practical pattern is to drive one parametrized test per locale from a table of (locale, count, expected) rows, so adding a new locale forces you to fill in its category-specific expectations rather than inheriting English behaviour by default.
If your formatter exposes an onError hook, assert that it is not called during the happy-path tests; an error that is swallowed into a fallback string is easy to miss in a green test run but renders garbage to the user. Wire the hook to your logger in non-production and to a counter metric in production so malformed-message rates are observable rather than invisible.
Common pitfalls
- Missing
otherarm. Everyplural,select, andselectordinalneeds one; the parser throws otherwise. Add it even when you think the value is bounded. - Using
pluralwhere you needselectordinal. Cardinal and ordinal categories differ; “2nd” needs the ordinal rule set. See ICU select/ordinal vs plural pitfalls. - Authoring only
one/other. This is silently wrong in Slavic and Arabic; map the full CLDR category set per locale, as detailed in handling pluralization in Arabic and Slavic languages. - Un-escaped literal braces or apostrophes. A literal
{must be quoted as'{', and''prints a literal apostrophe. Nested messages amplify this — see ICU nested select message escaping errors. #outside the nearest plural scope. Inside a nestedselect,#still binds to the enclosingpluralargument; reference the named variable if you mean something else.- Parsing at runtime in hot paths. Pre-compile to AST so the parser never ships to the client and bad strings fail the build instead.
FAQ
Does every plural and select really need an other arm?
Yes. ICU MessageFormat treats other as the mandatory universal fallback for plural, select, and selectordinal. Most parsers (intl-messageformat, formatjs) raise a syntax error at parse or compile time when it is absent, so the safest practice is to add other first and special-case from there.
When do I use selectordinal instead of plural?
Use selectordinal whenever the number is a rank or position — “1st place”, “3rd attempt”, “2nd floor”. It consults CLDR ordinal rules, which assign different categories than the cardinal rules used by plural. Picking plural for ranked copy produces correct-looking English but wrong output in many other locales.
What does # mean and can it appear anywhere?
# prints the current plural argument’s value formatted for the active locale (so 1000 becomes 1,000 in en and 1.000 in de). It is only meaningful inside a plural or selectordinal branch, and it always refers to the nearest enclosing such argument — not an inner select.
How do I include a literal {, }, or apostrophe?
Wrap a literal brace in single quotes: '{' and '}'. To print a literal apostrophe, double it: ''. A lone apostrophe before a special character starts a quoted span, which is a frequent source of “text disappeared” bugs in nested messages.
Should I compile messages at build time or parse at runtime?
Compile to AST at build time with @formatjs/cli. It moves syntax validation into CI (malformed strings fail the build), removes the parser from the client bundle, and eliminates repeated parse cost on hot render paths. Runtime parsing is acceptable only for low-volume server contexts.
Related
- ICU Message Format Syntax for Complex Plurals — multi-variable plural messages and exact-value matching in depth.
- ICU select/ordinal vs plural pitfalls — why ranked copy breaks when you reach for
plural. - ICU nested select message escaping errors — quoting braces and apostrophes inside nested constructs.
- Pluralization Rules Across Languages — the full CLDR category model your messages must match.
- Locale Negotiation Strategies — how the resolved locale that drives ICU evaluation is chosen.