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.
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/=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 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.
Related
- ICU Message Format Deep Dive — the full cluster on argument types, nesting, and formatter configuration.
- 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.
- Setting Up Graceful Fallback Chains for Missing Strings — what happens when a required category is absent.
Part of ICU Message Format Deep Dive.