Skip to content
Locale Lab

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.

Intl.PluralRulesICUMessageFormat

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

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

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

The demo

Try these, in order

Each step reproduces one specific failure in the demo below.

  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. An Arabic 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 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

РусскийRussian
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, which is why it cannot be hard-coded.

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 stringForms used
EnglishEnglish
one1 apple
oneother
SpanishEspañol
one1 manzana
onemanyother
FrenchFrançais
one1 pomme
onemanyother
GermanDeutsch
one1 Apfel
oneother
RussianРусский
one1 яблоко
onefewmanyother
PolishPolski
one1 jabłko
onefewmanyother
CzechČeština
one1 jablko
onefewmanyother
Arabicالعربية
oneتفاحة واحدة
zeroonetwofewmanyother
Chinese中文
other1 个苹果
other
Japanese日本語
other1個のリンゴ
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 is other.
  • In code review, flag any counted message that ships 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.
  • Two counts in one sentence (“3 files in 2 folders”) belong in one string with two plural arguments; 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

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 opened one in every language with more than two forms.

CLDR Language Plural Rules chart ↗

Terms in this chapter

Where to read more

Related chapters