Chapter 04·Three visible failures·~10 min
Plurals, ordinals & gender (ICU MessageFormat)
Most engineers assume there are two plural forms. CLDR defines up to six. The demo renders one noun's real translations across eleven languages as the count moves, then traces how an i18n runtime gets from a number to the final string.
The problem
English has two plural forms. Russian has four. Arabic has six. Code that says count === 1 ? "1 item" : `${count} items` produces grammatically broken sentences in roughly half of a typical locale mix, usually noticed only when native speakers file bug reports after launch.
Two sides have to be right. On the authoring side, a translator writes one arm per category their language uses (Russian gets four; Chinese gets one). On the consumption side, an i18n SDK at runtime picks the matching arm via CLDR rules, formats the number for the locale, and substitutes it in.
How it works
How a runtime turns 22 into the right string
Start with what the translator wrote. An ICU counted message carries one arm per grammatical shape the language uses: the translator chooses grammar, and the numbers stay out of it. Russian nouns take four shapes after a numeral, so a Russian catalog has four arms. Chinese needs one, and Arabic six.
At runtime the resolver takes three steps, each visible in the demo. First it looks for an exact match: an arm written =0 or =1 beats everything else. That is how "No files yet" wins over "0 files". If nothing matched exactly, it asks CLDR which category holds the number. Intl.PluralRules('ru').select(22) returns few, because Russian groups 2–4, 22–24 and 32–34 together, while 21 falls in one. Then it takes the winning arm and swaps # for the number, formatted for the locale. The French arm renders 1 000 000 where the English one renders 1,000,000.
The category names (zero, one, two, few, many, other) are abstract because they map to different numbers in different languages. one matches 21 in Russian but not in English, and French files its millions under many. Only other exists everywhere, and ICU refuses a plural message that does not include it. other is the arm that catches a value when the rules run out.
{count, plural,
one {# файл}
few {# файла}
many {# файлов}
other {# файла}}select: the same switch, minus the arithmetic
Gender (and any other enum-shaped variation) uses the same machinery with one difference: select does no number work at all. It compares the argument against the arm names as plain strings, with no CLDR and no categories: pass exactly female and the female arm renders.
It still demands an other arm, for the same reason plural does: the data will eventually hold a value the translator never saw. An unset field, a new enum member, or an API change can produce one.
The two compose. "She invited you to 3 events" is one message with select outside and plural nested inside, never two strings glued together in code. Gluing bakes English word order into the program, and other languages rearrange that order.
{gender, select,
female {{name} invited you to {count, plural, one {# event} other {# events}}}
male {{name} invited you to {count, plural, one {# event} other {# events}}}
other {{name} invited you to {count, plural, one {# event} other {# events}}}}The next version: MessageFormat 2.0
Everything above is ICU MessageFormat 1.0, the syntax most i18n libraries still speak. Unicode finalized a successor, MessageFormat 2.0 (MF2), as a stable specification in March 2025 (CLDR 47), after a final-candidate release in late 2024. It keeps the same job, letting the translator rather than the program pick the sentence shape, but redesigns the syntax to be easier to read, extend, and validate.
MF2 splits a message into .input declarations and a .match block, wraps each branch in a quoted {{...}} pattern, and runs values through pluggable functions like :number and :date. The catch-all arm is * rather than other. Multiple selectors compose in one .match block without the nested-brace ambiguity of the 1.0 form.
Intl.MessageFormat is still a TC39 proposal, and no browser ships it as a global, so today a polyfill such as the messageformat package fills the gap. MF2 matters now because new tooling and CLDR data lead with it, and a message written in 1.0 will eventually need porting.
# MessageFormat 1.0
{count, plural, one {# item} other {# items}}
# MessageFormat 2.0
.input {$count :number}
.match $count
one {{{$count} item}}
* {{{$count} items}}The demo
Try these, in order
Each step reproduces one specific failure in the demo below.
- 1With Russian selected, use the quick-jump buttons: 1, then 21, then 22. 21 returns to the same category as 1 (“one”), and 22 pairs with 2 (“few”). Read the number line: the pattern repeats every ten, tracking the final digit rather than the size of the number.
- 2Still in Russian, jump to 1.5. The category becomes “other”. Fractions never follow the integer rules: a discount of “1.5 дня” must not use the singular arm.
- 3Switch the language to Arabic (ar-SA) and step through 0, 1, 2, 3, 11, 100. Six different categories light up: zero, one, two, few, many, other. An Arabic string catalog needs an arm for each.
- 4Switch the mode to Ordinals and try 11, 12, 13, then 21, 22, 23. English 11th–13th all resolve “other” (-th), while 21st/22nd/23rd get three different suffixes. Ordinal rules are a separate rule set from cardinals.
- 5Switch the mode to Gender and click through female, male, and other / unknown. Each language card re-renders with full grammatical agreement rather than a pronoun swap. The other / unknown chip lands every language on its other arm: the arm ICU refuses to compile a select message without.
Language
Noun
Count
1 яблоко
Four forms. 1, 21, 31 are `one`; 2–4 are `few`; 5–20 are `many`.
ICU message source · Russian
How an i18n runtime resolves it
- 1 · input
count = 1n=1, locale=ru-RU - 2 · select category
new Intl.PluralRules("ru-RU").select(1)one - 3 · look up the arm
message[one]# яблоко - 4 · format #
new Intl.NumberFormat("ru-RU").format(1)1 - 5 · substitute & emit1 яблоко
▸ The same thing with a real library (intl-messageformat)
import { IntlMessageFormat } from "@formatjs/intl-messageformat";
const msg = new IntlMessageFormat(
"{n, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока}}",
"ru-RU",
);
msg.format({ n: 1 });
// → "1 яблоко"Category map · Russian
Every integer 0–29, coloured by the cardinal category it resolves to. Click any cell to set the count. The pattern repeats, which is why it cannot be hard-coded.
One message, every language
count = 1Each row is that language's own translation of “apple / apples”. It is not English with the category swapped. Click a row to focus it above.
| Language | Category | Resolved string | Forms used |
|---|---|---|---|
EnglishEnglish | one | 1 apple | oneother |
SpanishEspañol | one | 1 manzana | onemanyother |
FrenchFrançais | one | 1 pomme | onemanyother |
GermanDeutsch | one | 1 Apfel | oneother |
RussianРусский | one | 1 яблоко | onefewmanyother |
PolishPolski | one | 1 jabłko | onefewmanyother |
CzechČeština | one | 1 jablko | onefewmanyother |
Arabicالعربية | one | تفاحة واحدة | zeroonetwofewmanyother |
Chinese中文 | other | 1 个苹果 | other |
Japanese日本語 | other | 1個のリンゴ | other |
Korean한국어 | other | 사과 1개 | other |
The short version
Never branch on count === 1. Resolve a CLDR plural category, key strings by category, and let the runtime pick the arm.
What to do about it
- Use
Intl.PluralRules(or an ICU MessageFormat library like@formatjs/intl-messageformat) to resolve the plural category, then key translated strings by category. Never branch on the raw number. - Ship a string catalog whose keys cover every CLDR category for each target language, even when the source language only uses two. Translators fill the rest.
- The category is grammatical, not numeric: Russian 21 is
one, 1.5 isother. - In code review, flag any counted message that ships without ICU plural syntax. There is no good reason to ship
{count} itemsas a single string. - For ordinals (1st, 2nd, 3rd) the rule set is different: use
new Intl.PluralRules(locale, { type: "ordinal" }). For gender, use ICUselect, which composes inside a plural. - Two counts in one sentence (“3 files in 2 folders”) belong in one string with two
pluralarguments; don't split. Splitting bakes English word order and connectives into application code, and breaks in any language that reorders the parts (Japanese and Korean put the container first). See the Two counts tab.
Where this comes up
Who it concerns
Moments
- ·Code review
- ·Sprint planning when scoping a counted-message feature
- ·Onboarding a new translator
- ·Choosing or auditing an i18n SDK
See it in a market: Russian: 4 cardinal plural categories →
Field note
The classic bad fix starts with a bug report that says “1 items” looks wrong. An engineer adds count === 1 ? item : items, and the team ships it worldwide. Russian users now see the wrong form for 2, 5, 22, and most other counts: Russian needs four forms, and 22 takes the same form as 2, a different form than 21. The fix that closed the English bug opened one in every language with more than two forms.