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; the sibling question of which branching construct to use at all — plural versus selectordinal versus select — is covered in ICU select/ordinal vs plural pitfalls. This page assumes you have already chosen plural.
=0/=1) are tested against the raw count; only if none match does N − offset feed the CLDR rules. The offset shifts both the category and the # token, but never the exact-match test.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 full map of which integers each language sends to which category — and why the teens are the trap — lives in pluralization rules across languages; the six-category Arabic and digit-driven Slavic edges get their own treatment in handling pluralization in Arabic and Slavic languages.
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. A related subtlety: CLDR rules key on the visible form of the number, so 1.0 and 1 can resolve differently (the v/f/w operands count fractional digits) — in English 1 is one but 1.0 is other. If you feed a formatted or fractional value into a plural argument, select the category from the same representation the user will see.
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. Deeply nested messages amplify this: the deeper the tree, the more places a stray brace can unbalance the parse, which is exactly the failure class dissected in ICU nested select message escaping errors.
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/=1are matched against the rawcount, 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. Socount = 2renders… and #1 guest …. Quote any#,{, or}you want shown verbatim.fewis present because target locales (Polish, Russian, Arabic) need it; omitting it silently routes those values toother. 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 reference 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.
Related
- ICU Message Format Deep Dive — the full reference on argument types, nesting, and formatter configuration.
- ICU select/ordinal vs plural pitfalls — choosing the right construct before you reach for
plural. - ICU nested select message escaping errors — the brace and quote traps that break deeply nested arms.
- Pluralization Rules Across Languages — which CLDR categories each language actually uses.
- Handling Pluralization in Arabic and Slavic Languages — the six-category and few/many split traps in practice.
Part of ICU Message Format Deep Dive.