All lessons

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.

Intl.PluralRulesICUMessageFormat

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 {# файла}}
A Russian counted message: four arms, selected by CLDR rules, none of them by your if-statements.

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}}}}
select outside, plural inside: one message, every combination covered.

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}}
The same counted message: ICU MessageFormat 1.0 vs 2.0.

See it yourself

Try these, in order

Each step triggers a specific failure you should recognize on sight.

  1. 1
    With 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.
  2. 2
    Still 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.
  3. 3
    Switch 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.
  4. 4
    Switch 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.
  5. 5
    Switch 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

РусскийRussian
switch above, or click a row below
Resolved string
ru-RU·one

1 яблоко

Four forms. 1, 21, 31 are `one`; 2–4 are `few`; 5–20 are `many`.

ICU message source · Russian

{n, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока} }

How an i18n runtime resolves it

  1. 1 · inputcount = 1
    n=1, locale=ru-RU
  2. 2 · select categorynew Intl.PluralRules("ru-RU").select(1)
    one
  3. 3 · look up the armmessage[one]
    # яблоко
  4. 4 · format #new Intl.NumberFormat("ru-RU").format(1)
    1
  5. 5 · substitute & emit1 яблоко
`count === 1` happens to be correct here
naive1 яблокоCLDR1 яблоко
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.

4 categories:onefewmanyother

One message, every language

count = 1

Each 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.

LanguageCategoryResolved string
EnglishEnglish
one1 apple
SpanishEspañol
one1 manzana
FrenchFrançais
one1 pomme
GermanDeutsch
one1 Apfel
RussianРусский
one1 яблоко
PolishPolski
one1 jabłko
CzechČeština
one1 jablko
Arabicالعربية
oneتفاحة واحدة
Chinese中文
other1 个苹果
Japanese日本語
other1個のリンゴ
Korean한국어
other사과 1개

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 is other. 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} items as a single string.
  • For ordinals (1st, 2nd, 3rd) the rule set is different: use new Intl.PluralRules(locale, { type: "ordinal" }). For gender, use ICU select, which composes inside a plural.
  • Got two counts in one sentence (“3 files in 2 folders”)? Keep them in one string with two plural arguments; 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

EngineeringQATranslators

Moments

  • ·Code review
  • ·Sprint planning when scoping a counted-message feature
  • ·Onboarding a new translator
  • ·Choosing or auditing an i18n SDK

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.

CLDR Language Plural Rules chart

Quick check

3 questions · pass at 2+

  1. Question 1/3

    A message reads {g, select, female {…} male {…} other {…}} and your app passes g = "nonbinary". What renders?

  2. Question 2/3

    What does Intl.PluralRules('fr') return for 1,000,000?

  3. Question 3/3

    How many plural categories does CLDR define in total, and does any language use all of them?

Words you'll hear

Where to read more

Related lessons