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.

ICU MessageFormat parse and evaluation pipeline A raw ICU string is tokenized, parsed into an argument tree of plural, select, and selectordinal nodes, matched against CLDR plural rules for the active locale, and rendered to a final string. Raw ICU string {n, plural, one {#} ...} Parser / tokenizer brace + arg-type scan Message AST plural / select nodes CLDR plural rules locale → category Rendered string "3 results" Syntax error missing other / brace
Parse first, evaluate second: a malformed string never reaches the CLDR-driven evaluation stage.

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 as plural but 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 to Intl.NumberFormat / Intl.DateTimeFormat under 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 other arm. Every plural, select, and selectordinal needs one; the parser throws otherwise. Add it even when you think the value is bounded.
  • Using plural where you need selectordinal. 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 nested select, # still binds to the enclosing plural argument; 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.

Part of Core i18n Architecture & Locale Negotiation.