ICU select vs selectordinal vs plural pitfalls
The three ICU branching constructs plural, selectordinal, and select share an identical syntax but resolve their arms by three different rules — so reaching for plural to render “1st/2nd/3rd”, using select to count things, or naming a plural arm singular instead of one produces output that looks right in English and silently breaks in every other locale. This page pins down which construct each job needs, why the wrong choice passes review, and how to catch the mix-up before it ships.
All three accept the same {arg, type, key {text} …} shape, which is exactly why they get swapped. plural consults CLDR cardinal rules, selectordinal consults CLDR ordinal rules, and select ignores locale data entirely and matches the literal string you pass. Confuse them and nothing throws: the formatter happily routes the value to other and renders a grammatically wrong arm. These distinctions are the most error-prone corner of the broader ICU Message Format Deep Dive, because the failure is semantic, not syntactic — no parser, no compiler, and no English test will flag it.
Root cause analysis
The three constructs are deliberately uniform in syntax — that uniformity is the trap. The ICU grammar, aligned with Unicode TR35 (LDML), assigns each one a different resolution rule:
plural uses CLDR cardinal rules. The category keywords are zero, one, two, few, many, other, and which of them exist depends on the locale. English exposes only one and other; Polish adds few and many; Arabic exposes all six. A cardinal category answers “how many?” — 1 is one, 2 is other in English but few in Polish.
selectordinal uses CLDR ordinal rules — a completely separate data set. The same keyword strings (one, two, few, other) carry different meanings. In English ordinals, one means the “1st” suffix, two means “2nd”, few means “3rd”, and other covers “4th, 5th, …”. So selectordinal with English routes 1 → one, 2 → two, 3 → few, 4 → other, 21 → one (because “21st” ends in “st”). Feeding a rank to plural instead reuses cardinal rules, where 2 and 3 both land in other — producing “2th place” and “3th place”.
select is not locale-aware at all. It matches the literal string you pass against its arm keys. There are no CLDR categories; the keys are your own enum (female, male, admin, guest). Using select for a number means comparing "2" to your hand-written keys and falling through to other unless you literally wrote an arm named 2. Conversely, using plural for gender treats a gender string as a number, which never matches a numeric category.
The reason all three slip through review: each one is syntactically valid with any keyword. The parser accepts {place, plural, one{…} two{…} few{…} other{…}} — those are all legal cardinal keywords — and only the runtime, in a specific locale, exposes the wrong arm. There is no compile error to catch.
Minimal reproducible example
A ranked-result message authored with plural instead of selectordinal:
{place, plural,
one {#st place}
two {#nd place}
few {#rd place}
other {#th place}
}
Render this in English. The keywords are legal cardinal categories, so nothing throws — but cardinal rules route 1 → one, then 2, 3, 4, … all to other. The output is “1st place”, then “2th place”, “3th place”, “4th place”. The two and few arms are dead code that never fires.
The mirror-image mistake uses select to count:
{count, select,
one {# item}
other {# items}
}
Here select compares the literal string value to the keys one and other. Passing count = 1 compares "1" (or the number 1) against "one" — no match — and falls through to other, rendering “1 items”. And inside a select, # is not the special plural token at all; it is a literal # character, so the output is a stray hash, not the formatted number.
A third common slip is the wrong category keyword in an otherwise-correct plural:
{count, plural,
singular {# item}
plural {# items}
}
singular and plural are not CLDR keywords. ICU treats unknown bare words as never-matching, so every value falls to… nothing — and because there is no other arm, most parsers throw Missing select case "other". If an other arm were present, both made-up arms would be silently dead.
The fix, annotated
Match each construct to its job, and use the exact CLDR keywords:
{place, selectordinal,
one {#st place} // English ordinal: 1st, 21st, 31st …
two {#nd place} // 2nd, 22nd …
few {#rd place} // 3rd, 23rd …
other {#th place} // 4th, 5th, 11th, 12th, 13th …
}
Annotations on the non-obvious lines:
selectordinalis what makesone/two/fewmean “1st/2nd/3rd”. Underplural, those same keywords carry cardinal meaning and the suffixes break. The keyword strings are identical across the two constructs; only the construct name changes their meaning.#is the locale-formatted value — valid here becauseselectordinal(likeplural) defines the#token. It would be a literal hash inside aselect.- The
otherarm is mandatory and correctly catches11th,12th,13th, which English ordinal rules route toothereven though they end in 1/2/3. That edge is exactly why you must not hand-roll suffix logic.
For the counting case, use plural; for the non-numeric case, use select with stable enum keys:
{count, plural,
=0 {No items} // exact match, checked before categories
one {# item} // cardinal one
other {# items} // cardinal other (+ few/many in Slavic, etc.)
}
{gender, select,
female {She replied}
male {He replied}
other {They replied} // required fallback; also covers unknown/undisclosed
}
The select keys (female, male) are part of your application contract, like enum values — translators must never rename them, or every value routes to other. This is the inverse of plural, where you do not choose the keys; CLDR does. When a select for gender wraps a plural for count, each construct keeps its own rule set, a composition detailed in ICU MessageFormat syntax for complex plurals. Authoring the full CLDR keyword set per language is covered in pluralization rules across languages, and the Slavic/Arabic few/many traps in handling pluralization in Arabic and Slavic languages.
Verification snippet
Prove the construct choice with assertions that exercise the exact values where cardinal and ordinal rules diverge — 2, 3, and the 11–13 exception:
import { IntlMessageFormat } from 'intl-messageformat';
const ordinal = new IntlMessageFormat(
`{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`,
'en',
);
console.assert(ordinal.format({ place: 1 }) === '1st', 'ordinal 1');
console.assert(ordinal.format({ place: 2 }) === '2nd', 'ordinal 2');
console.assert(ordinal.format({ place: 3 }) === '3rd', 'ordinal 3');
console.assert(ordinal.format({ place: 11 }) === '11th', '11..13 exception');
console.assert(ordinal.format({ place: 21 }) === '21st', '21 ends in st');
// The same string under `plural` would render 2th / 3th — a fast regression guard:
const wrong = new IntlMessageFormat(
`{place, plural, one {#st} two {#nd} few {#rd} other {#th}}`,
'en',
);
console.assert(wrong.format({ place: 2 }) === '2th', 'plural mis-routes ranks');
Cross-check which category a value resolves to before authoring arms, using Intl.PluralRules with the matching type:
const ord = new Intl.PluralRules('en', { type: 'ordinal' });
[1, 2, 3, 4, 11, 21].forEach((n) => console.log(n, '→', ord.select(n)));
// 1 → one, 2 → two, 3 → few, 4 → other, 11 → other, 21 → one
const card = new Intl.PluralRules('en', { type: 'cardinal' });
console.log(2, '→', card.select(2)); // 2 → other (why `two`/`few` are dead under plural)
A lint rule blocks the mix-up before runtime. eslint-plugin-formatjs flags non-CLDR keywords and missing other arms at build time:
{
"plugins": ["formatjs"],
"rules": {
"formatjs/no-invalid-icu": "error",
"formatjs/enforce-plural-rules": ["error", { "one": true, "other": true }]
}
}
When to escalate
If the construct is correct and the keywords are valid CLDR categories but output is still wrong, the problem has moved below the message into the locale’s rule data. A stale or mismatched @formatjs/intl-pluralrules / intl-pluralrules polyfill can ship cardinal ordinal data, or omit ordinal rules entirely, so selectordinal silently degrades to other for every value — a drift no keyword edit fixes. The same applies when server and client run different CLDR versions and disagree on a boundary. When the category itself looks wrong for a specific language rather than the construct, the issue belongs to that language’s rule set and the formatter configuration covered by the ICU Message Format Deep Dive cluster, which addresses polyfill pinning across server and client.
FAQ
Why does my selectordinal message work in plural for English but break elsewhere?
It does not actually work in plural — it only looks right for 1. Under plural (cardinal rules) English routes 1 → one so “1st” renders, but 2 and 3 route to other, giving “2th” and “3th”. You happened to test 1. selectordinal uses ordinal rules where 2 → two and 3 → few, producing “2nd” and “3rd” correctly, and it scales to other locales’ ordinal patterns too.
Can I use select to handle a count if I write one and other arms?
No. select matches the literal string you pass against its arm keys with no locale logic, so count = 1 compares "1" to the key "one", fails, and falls through to other. It also treats # as a literal hash rather than the formatted value. Counting requires plural, which consults CLDR cardinal rules to choose the arm.
What happens if I use a made-up keyword like singular or 1st in a plural arm?
ICU only matches the CLDR keywords (zero, one, two, few, many, other) plus =N exact matches. A bare word like singular is never selected, so that arm is dead code. If you also omit the required other arm, the parser throws a missing-other error; if other is present, every value silently routes there.
Related
- ICU Message Format Deep Dive — the full cluster on argument types, nesting, and formatter configuration.
- ICU MessageFormat Syntax for Complex Plurals — exact-match selectors, offsets, and nesting
pluralinsideselect. - Pluralization Rules Across Languages — which CLDR cardinal and ordinal categories each language actually uses.
- Handling Pluralization in Arabic and Slavic Languages — the
few/manyand six-category traps that compound this mistake.
Part of ICU Message Format Deep Dive.