Pluralization Rules Across Languages
Pick the wrong plural form for a count and you ship strings like “3 item” or “1 файлов” — grammatically broken text that no spell-checker catches and that QA only spots in the target language. The cause is almost always the same mistake: treating plurals as a binary “one vs. many” switch when CLDR defines up to six plural categories — zero, one, two, few, many, other — and assigns them differently in every language. Arabic uses all six. Welsh uses five. English uses two. Japanese uses one. This page shows how to select the correct category at runtime with Intl.PluralRules, author messages that survive translation, and gate the whole thing in CI before broken grammar reaches users.
other.Prerequisites
Concept & spec: what a “plural category” actually is
A plural category is a grammatical bucket. For a given language, every number — and, with ICU, every formatted number including decimals — maps to exactly one of six possible categories: zero, one, two, few, many, other. The category names are universal, but which numbers land in which bucket is per-language, and a language only uses the buckets it grammatically needs.
The authority is the Unicode CLDR Language Plural Rules data, specified in Unicode TR35 Part 2 (Numbers). Crucially, one does not mean “the number 1” — it means “the form this language uses for quantities that behave grammatically like one.” In English one is exactly n = 1. In Russian one covers 1, 21, 31, 101… (numbers ending in 1 but not 11). In French, one covers both 0 and 1. The categories are abstractions over morphology, not arithmetic.
The runtime contract is ECMA-402’s Intl.PluralRules, which exposes exactly these CLDR rules to JavaScript. This sits inside the broader Core i18n Architecture & Locale Negotiation area: pluralization runs after the locale is resolved and before the final string is composed, and it shares its category machinery with the ICU Message Format Deep Dive — ICU plural blocks call the same CLDR rules under the hood.
CLDR distinguishes two rule sets, selected by type:
- Cardinal (
type: 'cardinal', the default) answers “how many?” — 1 file, 5 files. - Ordinal (
type: 'ordinal') answers “in what position?” — 1st, 2nd, 3rd. English has four ordinal categories (one→ “1st”,two→ “2nd”,few→ “3rd”,other→ “4th”), so a count formatter and a rank formatter need different rule sets even within one language.
Step-by-step implementation
Step 1 — Resolve the category, never hardcode the threshold
Construct an Intl.PluralRules instance per locale and call .select() with the count. Never write count === 1 ? singular : plural: that logic is wrong for every language except English-like ones, and silently wrong (no error, just bad grammar).
const pr = new Intl.PluralRules('ru', { type: 'cardinal' });
pr.select(1); // 'one' → "1 файл"
pr.select(2); // 'few' → "2 файла"
pr.select(5); // 'many' → "5 файлов"
pr.select(21); // 'one' → "21 файл"
The same call for 'en' returns only 'one' or 'other'; for 'ar' it can return any of the six. Your code does not branch on the language — CLDR does.
Step 2 — Key your messages by category, with other mandatory
Store one string per category the target language uses, and always provide other as the floor. The keys are the CLDR category names; selection picks the matching key or degrades to other.
type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
function plural(locale: string, count: number, forms: PluralForms): string {
const cat = new Intl.PluralRules(locale).select(count);
const template = forms[cat] ?? forms.other; // 'other' is always defined
return template.replace('#', new Intl.NumberFormat(locale).format(count));
}
plural('pl', 5, { one: '# plik', few: '# pliki', many: '# plików', other: '# pliku' });
// → "5 plików"
Step 3 — Prefer ICU MessageFormat for translator-authored strings
Hand-rolled forms maps are fine for code-owned strings, but translators need a single self-contained string. ICU plural blocks embed every category inline; the # token is replaced by the locale-formatted count, and =N exact matches (e.g. =0) override category rules for special-cased copy.
{count, plural,
=0 {No files}
one {# file}
few {# files}
many {# files}
other {# files}}
Let the library (formatjs, intl-messageformat, i18next-icu) evaluate this. Do not parse ICU yourself — the spec’s escaping and nesting rules are covered in the ICU Message Format Deep Dive.
Step 4 — Handle ordinals with the right type
For “1st place” UI, switch to ordinal rules and select a suffix by category. Reusing the cardinal one/other split here produces “1th”/“2th” in English.
const ord = new Intl.PluralRules('en', { type: 'ordinal' });
const suffix = { one: 'st', two: 'nd', few: 'rd', other: 'th' } as const;
const rank = 22;
`${rank}${suffix[ord.select(rank)]}`; // ord.select(22) → 'two' → "22nd"
Configuration reference
| Option | Type | Description / default |
|---|---|---|
locale |
string (BCP-47) |
First constructor argument; the resolved tag whose CLDR rules apply. No default — pass an explicit tag, never rely on the host. |
type |
'cardinal' | 'ordinal' |
Which rule set to use. Default 'cardinal'. Use 'ordinal' only for rank UI. |
minimumFractionDigits |
number |
Affects category for fractional counts (e.g. 1.0 is other, not one, in many locales). Default 0. |
maximumFractionDigits |
number |
Upper bound on fraction digits considered during selection. Default depends on locale. |
minimumSignificantDigits |
number |
Alternative precision model; influences which visible digits drive the rule. Optional. |
roundingMode |
string |
How fractional input rounds before selection (Node 18+/modern engines). Default 'halfExpand'. |
pr.resolvedOptions().pluralCategories returns the array of categories the chosen locale actually uses — drive your catalog validation off that, never off a hardcoded list.
Framework variants
React / Next.js (react-intl / formatjs). Author ICU plural strings in your message catalog and render with <FormattedMessage id="files" values={{ count }} /> or intl.formatMessage. formatjs validates ICU syntax at extraction time, so a missing brace fails the build rather than the browser.
Vue / Nuxt (vue-i18n). vue-i18n’s native |-delimited plural syntax only supports a positional 2–3 form list and does not implement full CLDR — wire pluralizationRules per locale, or switch the message format to ICU so few/many resolve correctly for Slavic and Arabic.
Angular. $localize and the i18nPlural pipe accept ICU plural blocks directly in templates; the Angular compiler extracts them into XLIFF, preserving each category as a separate translation unit.
Node.js backend. Use Intl.PluralRules directly (Steps 1–2) or intl-messageformat for ICU strings. Confirm the runtime ships full ICU — a small-icu build collapses many locales to English rules, so pr.select(5) for Russian wrongly returns 'other'.
Verification
Assert category selection against CLDR-known values per locale, and check that every catalog entry covers the categories its locale requires.
import { test, expect } from 'vitest';
test('Russian cardinal categories', () => {
const pr = new Intl.PluralRules('ru');
expect(pr.select(1)).toBe('one');
expect(pr.select(3)).toBe('few');
expect(pr.select(5)).toBe('many');
expect(pr.select(0)).toBe('many');
});
test('catalog covers required categories', () => {
const required = new Intl.PluralRules('ar').resolvedOptions().pluralCategories;
const provided = Object.keys(catalogs.ar.items_count);
for (const cat of required) expect(provided).toContain(cat);
});
In CI, fail the job when any locale’s catalog omits a required category — that single gate prevents the “3 item” class of bug from ever merging.
Common pitfalls
- Binary thinking. Assuming
one/otheris enough dropsfew/manyfor Slavic and the full set for Arabic. Drive variants offresolvedOptions().pluralCategories. See Handling Pluralization in Arabic and Slavic Languages. onemeans “1”. It does not. Russianoneincludes 21 and 101; Frenchoneincludes 0. Trust.select(), not your intuition about the number.small-icuruntime. A trimmed ICU build silently collapses non-English locales toone/other. Verifyprocess.versions.icuandpr.resolvedOptions().pluralCategories.length.- Catalog vs. CLDR drift. A TMS exports a category your locale doesn’t use (or omits one it needs). When
.select()returns a category with no string, you get a blank or wrong form — diagnose with CLDR Plural Category Mismatch Debugging. - Cardinal rules for ordinals. Produces “1th”. Pass
type: 'ordinal'for rank UI. - Hand-replacing
#outside ICU. If you bypass the ICU library you also bypass locale number formatting; always run the count throughIntl.NumberFormat.
FAQ
Why does Intl.PluralRules('en').select(1.5) return other, not one?
In English CLDR cardinal rules, one is defined for the integer value 1 with no visible fraction digits. Any value with a fractional part (1.5, 1.0 when displayed as “1.0”) falls into other. This is why “1.5 stars” is grammatically plural in English. The minimum/maximumFractionDigits options influence this because they change which digits are “visible” for the rule.
Is zero the same as the count being 0?
No. zero is a grammatical category that only a few languages (Arabic, Welsh, Latvian) use, and it does not always correspond to the number 0 — Arabic’s zero rule matches n = 0, but most languages route 0 into other (English) or one (French). To special-case the literal number zero in copy (“No files”), use an ICU =0 exact match, which is independent of the CLDR zero category.
Can I derive plural categories from the language part of the locale alone?
Mostly, yes — CLDR plural rules key off the language subtag (pt, not pt-BR), with a handful of region-sensitive exceptions. Still pass the full resolved tag to Intl.PluralRules; it normalizes correctly, and hardcoding the bare language risks missing the rare regional rule and couples you to assumptions CLDR may revise.
Do I need separate strings for cardinal and ordinal, or can one catalog entry cover both?
They are different rule sets and almost always different copy (“3 files” vs. “3rd file”), so keep them as separate catalog entries. A single ICU string can only carry one selectordinal or plural block per placeholder; mixing them means two placeholders or two messages.
Related
- Handling Pluralization in Arabic and Slavic Languages — the dual forms, exact-value rules, and final-digit logic that make these families the hardest cases.
- CLDR Plural Category Mismatch Debugging — what to do when
.select()returns a category your catalog never provided. - ICU Message Format Deep Dive — authoring
plural/selectordinalblocks and the escaping rules translators trip over. - Locale Negotiation Strategies — resolving the BCP-47 tag that pluralization depends on before any count is formatted.