Lesson 04·Unit 2 · Three failures you can see·Numeric agreement & gender selection·Lv 101·~10 min
Plurals, ordinals & gender (ICU MessageFormat)
Most engineers assume there are two plural forms. CLDR defines up to six. Pick a noun, drag the count, and watch each language resolve its own real translation, then trace exactly how an i18n runtime gets from a number to the final string.
By the end
- ·Resolve a count through Intl.PluralRules instead of branching on count === 1.
- ·Explain why the categories track grammar rather than size: Russian 21 is one, 1.5 is other, French millions are many.
- ·Say how many categories CLDR defines (six), who uses all of them, and why ordinals run on separate rules.
- ·Describe what select does instead: a plain keyword match, no CLDR lookup, and an other arm you still cannot skip.
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 your target locales, usually noticed only after a launch-week bug report from a native-speaker user. The cost is trust, not strings.
There are two sides to getting this right, and this demo shows both. On the authoring side, a translator writes one arm per category their language actually 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. Get either side wrong and the string breaks.
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. 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, and you can follow every one. 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 your 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 by design, 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 {# файла}}How it works
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: pass exactly female and the female arm renders. No CLDR, no categories, only a keyword match.
It still demands an other arm, for the same reason plural does: your data will eventually hold a value the translator never saw. An unset field, a new enum member, or an API change can produce one. other is the catch-all arm, and ICU rejects the message without it.
The two compose. "She invited you to 3 events" is one message with select outside and plural nested inside, not two strings glued together in code. Gluing bakes English word order into your 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}}}}How it works
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 2024. It keeps the same job, letting the translator rather than your code 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. If you have ever nested two selectors in a 1.0 message and lost track of the braces, watch for the cleaner multi-selector handling.
There is no built-in JavaScript API yet. Intl.MessageFormat is still a TC39 proposal, and no browser ships it as a global, so today you use a polyfill such as the messageformat package. 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}}See it yourself
Try these, in order
Each step triggers a specific failure you should recognize on sight.
- 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. Your 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, not a pronoun swap. The other / unknown chip lands every language on its other arm: the arm ICU refuses to compile a select message without.
Your language · center the demo on one you speak or are learning
Noun
Count · drag, type, or jump to an interesting value
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. That is why you cannot hard-code it.
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 |
If you remember one thing
Never branch on count === 1. Resolve a CLDR plural category, key your 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 your translated strings by category. Never branch on the raw number yourself. - Ship a string catalog whose keys cover every CLDR category for the language you're shipping into, even if your source language only uses two. Translators fill the rest.
- Remember the category is grammatical, not numeric: Russian 21 is
one, 1.5 isother. The number alone never tells you the form. - Quality bar: flag any code review that ships a counted message 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. - Got two counts in one sentence (“3 files in 2 folders”)? Keep them in one string with two
pluralarguments; don't split. Splitting bakes English word order and connectives into your code, and breaks in any language that reorders the parts (Japanese and Korean put the container first). See the Two counts tab.
Use this with
Stakeholders
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 created dozens of invisible ones.
Quick check
3 questions · pass at 2+
Question 1/3
A message reads {g, select, female {…} male {…} other {…}} and your app passes g = "nonbinary". What renders?
Question 2/3
What does Intl.PluralRules('fr') return for 1,000,000?
Question 3/3
How many plural categories does CLDR define in total, and does any language use all of them?