ICU MessageFormat Syntax for Complex Plurals

ICU complex plurals break when you confuse the exact-match =0/=1 selectors with the CLDR categories one/few/many/other, mis-place the # token under an offset:, or nest plural inside select without balancing braces. This page walks the exact syntax for combining plural with # offset:, nesting plural inside select, the difference between an exact-match selector and a plural category, and the three syntax errors that produce the most cryptic parser failures.

A message like {count, plural, offset:1 =0{...} one{# guest} other{# guests}} reads cleanly until you ask: does =1 match before one? Does # show count or count - offset? What happens when count is 1 but the locale is Polish? Getting these wrong produces grammatically invalid output that no compiler catches, because the ICU parser accepts the string — it just resolves the wrong arm at runtime. The rules below are part of the broader ICU Message Format Deep Dive, narrowed to the plural-specific traps.

ICU plural selector resolution order For a given count, ICU first tries exact-match selectors like =0 or =1; only if none match does it apply CLDR plural rules to pick one, few, many, or other. The offset is subtracted before the hash token is formatted but not before category selection. count = N runtime value exact match? =0 / =1 / =N use exact arm category skipped CLDR category one/few/many/other # shows N − offset yes no
Exact-match selectors are tried before CLDR categories; the offset is subtracted only when formatting the # token, not when picking the category.

Root cause analysis

Three independent rules collide in a complex plural, and each has a counter-intuitive edge.

Exact-match wins over category. A selector written =1 is a literal numeric match on the raw count. A selector written one (no =) is a CLDR category resolved by Intl.PluralRules. ICU checks every =N selector first; only if none match does it run plural rules to pick a category. So a message with both =1{...} and one{...} will use the =1 arm for count === 1 and the one arm for every other value the locale routes to one (in English, none — but in many locales one covers values like 21, 31). Mixing them up is the single most common semantic bug: developers write one expecting it to mean “exactly one” and are surprised when Russian routes 21 there.

The offset adjusts #, not category selection. offset:1 subtracts 1 from the value before # is formatted, so “you and N others” math works. But the CLDR category is still chosen from the offset-adjusted value per the ICU spec — meaning offset:1 with count = 2 selects the category for 1. This is the ICU TR35 / CLDR plural-rule behaviour and is easy to get backwards. The =0/=1 exact selectors, however, are matched against the original value, not the offset-adjusted one.

Nesting is just substitution. plural and select nest freely — a select arm can contain a whole plural, and vice versa. The parser does not limit depth. What it does not tolerate is an unescaped literal #, {, or } inside an arm, or a stray space inside a selector like = 0.

Minimal reproducible example

The smallest message that triggers all three traps at once:

{count, plural, offset:1
  =0 {Nobody is here}
  =1 {Only you are here}
  one {You and # other}
  other {You and # others}
}

Reproduce the surprise: render with count = 22 in pl (Polish). You expect other; you get few (Polish routes 22 to few), and # shows 21, not 22, because of the offset. Now add a stray space — = 0 {...} — and the formatter throws at parse time:

SyntaxError: Expected "}", "=", or argument but " " found.

A second classic failure is an unescaped # meant as a literal:

{count, plural, one {Issue #} other {Issues}}

Here # is consumed as the value placeholder, not a literal hash, so count = 1 renders Issue 1 rather than Issue #.

The fix, annotated

Decide deliberately between exact match and category, escape literals, and keep offset semantics explicit:

{count, plural, offset:1
  =0 {Nobody else is here.}
  =1 {Just you so far.}
  one {You and '#'# guest are in the room.}
  few {You and '#'# guests are in the room.}
  other {You and '#'# guests are in the room.}
}

Annotations on the non-obvious lines:

  • =0/=1 are matched against the raw count, so they short-circuit before any locale math — use them for fixed copy that must never inflect.
  • '#' is a single-quoted literal hash; the bare # immediately after it is the offset-adjusted value. So count = 2 renders … and #1 guest …. Quote any #, {, or } you want shown verbatim.
  • few is present because target locales (Polish, Russian, Arabic) need it; omitting it silently routes those values to other. The same discipline matters when you hand-craft fallback chains for missing strings — a missing category is a missing string.

Nesting plural inside select for gendered, counted copy:

{gender, select,
  female {{count, plural, one {She has # message} other {She has # messages}}}
  male   {{count, plural, one {He has # message}  other {He has # messages}}}
  other  {{count, plural, one {They have # message} other {They have # messages}}}
}

Each select arm wraps a complete {count, plural, …} block; the double braces are the select arm braces plus the nested argument braces — not a typo. This composition is the canonical way to combine grammatical pluralization across languages with a second dimension like gender.

Verification snippet

Prove category selection and offset behaviour without a UI, then assert in CI:

import { IntlMessageFormat } from 'intl-messageformat';

const msg = new IntlMessageFormat(
  `{count, plural, offset:1 =0 {none} =1 {you} one {you +'#'#} other {you +'#'#}}`,
  'en',
);

// Exact match beats category and ignores the offset on selection:
console.assert(msg.format({ count: 0 }) === 'none', '=0 must win');
console.assert(msg.format({ count: 1 }) === 'you', '=1 must win');
// offset:1 => # shows count - 1, and '#' is a literal hash:
console.assert(msg.format({ count: 3 }) === 'you +#2', 'offset applied to #');

A static gate catches the worst syntax errors before runtime. Add eslint-plugin-formatjs to fail the build on missing categories and malformed selectors:

{
  "plugins": ["formatjs"],
  "rules": {
    "formatjs/no-multiple-whitespaces": "error",
    "formatjs/enforce-plural-rules": ["error", { "one": true, "other": true }]
  }
}

For locale boundary checks, query Intl.PluralRules directly to see which category a value resolves to before you decide which arms to author:

const pr = new Intl.PluralRules('pl', { type: 'cardinal' });
[1, 2, 5, 22, 25].forEach((n) => console.log(n, '→', pr.select(n)));
// 1 → one, 2 → few, 5 → many, 22 → few, 25 → many

When to escalate

If output is still wrong after auditing selectors, offset, and escaping, the problem has likely moved below the message syntax into CLDR data. Mismatched @formatjs/intl-pluralrules polyfill versions between build and runtime can shift a category boundary so that count = 5 resolves to few in one environment and many in another — a silent drift no message edit will fix. When the category itself looks wrong for a specific language, the issue belongs to the language’s rule set rather than this syntax, and the ICU Message Format Deep Dive cluster covers the formatter-level configuration and polyfill pinning needed to stabilise it across server and client.

FAQ

Does =1 match before one in an ICU plural?

Yes. ICU evaluates all exact-match selectors (=0, =1, =N) against the raw value first. Only if none match does it run CLDR plural rules to choose a category (one, few, many, other). So =1{...} always handles count === 1, and the one{...} arm handles whatever other values the active locale routes to one.

What value does # show when an offset: is set?

# shows the value after the offset is subtracted. With offset:1 and count = 5, # renders 4. The CLDR category is also chosen from the offset-adjusted value, but the =N exact selectors are matched against the original, un-offset value.

How do I print a literal # or brace inside a plural arm?

Wrap it in single quotes: '#' renders a literal hash, '{' a literal brace. An unquoted # is consumed as the value placeholder, and unbalanced or unquoted braces throw a SyntaxError at parse time. A space inside a selector (= 0) also fails to parse.

Part of ICU Message Format Deep Dive.