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.

Count plus locale to CLDR plural category to message A numeric count and a resolved locale feed Intl.PluralRules, which returns one of six CLDR categories; the category selects a translated variant, falling back to other when a category is missing. count = 3 numeric input locale = ar resolved locale Intl.PluralRules .select(3) zero one two few ← many other missing category? fall back to "other"
The count and resolved locale select a CLDR category; a missing variant degrades to 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/other is enough drops few/many for Slavic and the full set for Arabic. Drive variants off resolvedOptions().pluralCategories. See Handling Pluralization in Arabic and Slavic Languages.
  • one means “1”. It does not. Russian one includes 21 and 101; French one includes 0. Trust .select(), not your intuition about the number.
  • small-icu runtime. A trimmed ICU build silently collapses non-English locales to one/other. Verify process.versions.icu and pr.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 through Intl.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.

Part of Core i18n Architecture & Locale Negotiation.