Handling Pluralization in Arabic and Slavic Languages

A count === 1 ? singular : plural ternary silently renders grammatically broken text in Arabic and Slavic locales because both grammars carve the number line into more than two categories — Arabic into six and Russian, Polish, and Czech into a digit-dependent three. The English-centric one/other model never errors; it just picks the wrong noun ending, so QA sees “3 файлы” instead of “3 файла” and assumes the translation is at fault. This page traces why the mis-render is invisible, maps the CLDR plural categories each language actually uses, and gives the exact react-intl / i18next fix.

These rules are the runtime expression of the pluralization rules across languages every multi-region product has to encode. Get the category mapping wrong and the defect surfaces only on specific integers — which is what makes it so easy to ship.

Number-to-category mapping across English, Russian, and Arabic The same integer 3 maps to category "other" in English but "few" in Russian and Arabic; English-centric one/other code silently collapses few and many into other. Input integer 3 CLDR category resolver English (en) one / other Russian (ru) one / few / many / other Arabic (ar) zero/one/two/few/many/other → other "3 items" (correct) → few "3 файла" (needs few) → few "3 عناصر" (needs few)
The integer 3 resolves to other in English but few in Russian and Arabic — a binary ternary serves the English form to every locale.

Root cause: why the mis-render is silent

The defect lives in a category-space mismatch, not in a thrown error. The Unicode CLDR plural rules (Unicode TR35, Part 4 — Language Plural Rules) define up to six categories — zero, one, two, few, many, other — and assign each language a subset. English uses exactly two (one for n = 1, other for everything else), so a count === 1 branch happens to be correct for English. That coincidence is the trap: the same code is grammatically wrong everywhere else but never raises.

Arabic uses all six categories. Russian, Polish, and Czech each use a few/many split driven by the last digit and the decade, not by magnitude:

Language Categories used Example: few rule Example: many rule
English (en) one, other
Russian (ru) one, few, many, other last digit 2–4, not 12–14 (2, 3, 24) last digit 0, 5–9, or 11–14 (5, 11, 100)
Polish (pl) one, few, many, other last digit 2–4, not 12–14 (2, 22) the rest (5, 12, 25)
Czech (cs) one, few, other integer 2–4 (2, 3, 4) fractional (uses many)
Arabic (ar) zero, one, two, few, many, other n % 100 in 3–10 (103, 1003) n % 100 in 11–99 (111, 11)

The killer cases are the teens: in Russian, 11, 12, 13, 14 are many, not few, even though they end in 14. A naive “ends in 2–4 → few” heuristic ships wrong endings on exactly those integers, and because nobody writes a test for 12, it reaches production. The fix is never to author the rules by hand — delegate category resolution to a CLDR-backed engine (Intl.PluralRules or ICU MessageFormat) and key your translations on the category name. When the categories the engine emits disagree with the keys in your catalog, that is the CLDR plural category mismatch you have to debug.

Minimal reproducible example

The bug in its smallest form — a binary ternary fed a Russian string:

// BROKEN: English-shaped logic applied to Russian
function fileCount(n: number): string {
  return n === 1 ? `${n} файл` : `${n} файла`; // only ever "файл" or "файла"
}

fileCount(1);  // "1 файл"   ✓ (one)
fileCount(3);  // "3 файла"  ✓ (few — by luck)
fileCount(5);  // "5 файла"  ✗ should be "5 файлов" (many)
fileCount(12); // "12 файла" ✗ should be "12 файлов" (many)

fileCount(3) happens to be right, which is what makes the bug survive review — a spot-check on 1 and 3 looks fine. Only 5, 11, and 12 expose the missing many category.

The fix: react-intl (formatjs)

react-intl resolves categories through Intl.PluralRules under the hood, so the correct fix is to stop branching in JavaScript and move the decision into an ICU MessageFormat string. Each translated locale supplies only the categories CLDR says it needs; the formatter picks the right arm for any integer.

import { IntlProvider, FormattedMessage } from 'react-intl';

// Russian catalog — keyed on CLDR categories, not magnitudes
const messages = {
  ru: {
    // one/few/many/other arms; '#' interpolates the number
    fileCount: '{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}',
  },
};

function Files({ count }: { count: number }) {
  // No count === 1 logic here — the formatter resolves the category
  return <FormattedMessage id="fileCount" values={{ count }} />;
}

// <IntlProvider locale="ru" messages={messages.ru}>…</IntlProvider>
// count=5  → "5 файлов" (many)   count=12 → "12 файлов" (many)
// count=3  → "3 файла"  (few)    count=1  → "1 файл"    (one)

For Arabic, author all six arms in the ar catalog so count=0 and count=2 get their dedicated grammatical forms rather than collapsing into other:

{count, plural,
  zero {لا توجد عناصر}
  one  {عنصر واحد}
  two  {عنصران}
  few  {# عناصر}
  many {# عنصرًا}
  other {# عنصر}}

The fix: i18next

i18next ≥ v21 also delegates to Intl.PluralRules. Instead of one ICU string, it expands plurals into suffixed keys, one per category. The resolver appends the CLDR suffix (_one, _few, _many, _other) automatically — you never write a branch:

// locales/ru/translation.json — one key per CLDR category
{
  "fileCount_one":   "{{count}} файл",
  "fileCount_few":   "{{count}} файла",
  "fileCount_many":  "{{count}} файлов",
  "fileCount_other": "{{count}} файла"
}
import i18next from 'i18next';

// compatibilityJSON: 'v4' is required for CLDR (one/few/many) suffixes.
// Without it, i18next falls back to numeric _0/_1/_2 keys and the
// category names above never match — every count renders the fallback.
await i18next.init({ lng: 'ru', compatibilityJSON: 'v4', resources: { /* … */ } });

i18next.t('fileCount', { count: 5 });  // "5 файлов" (many)
i18next.t('fileCount', { count: 12 }); // "12 файлов" (many)

The single most common i18next plural bug is omitting compatibilityJSON: 'v4': older defaults used numeric suffixes (_0, _1, _2) that do not correspond to CLDR category names, so your _few/_many keys silently never resolve.

Verification snippet

Assert the category boundaries that the ternary gets wrong — teens and the 5+ band — not just 1 and a stray 3:

import { test, expect } from 'vitest';

const ru = new IntlMessageFormat(
  '{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}',
  'ru',
);
const fmt = (count: number) => ru.format({ count }) as string;

test.each([
  [1,  '1 файл'],    // one
  [3,  '3 файла'],   // few
  [5,  '5 файлов'],  // many  ← ternary fails here
  [11, '11 файлов'], // many  ← teen: ends in 1 but is many
  [12, '12 файлов'], // many  ← teen: ends in 2 but is many, not few
  [22, '22 файла'],  // few   ← ends in 2, not a teen → few
])('ru plural %i', (n, expected) => {
  expect(fmt(n)).toBe(expected);
});

Run the same matrix in CI for every plural-bearing locale and fail the build on any other fallback for a count that should have hit a defined category. A quick Intl.PluralRules cross-check confirms the engine agrees with your catalog keys:

const pr = new Intl.PluralRules('ru');
console.log(pr.select(12)); // "many"  — your catalog needs a _many / many arm

When to escalate

This fix covers integer counts in catalogs you control. It is insufficient when category names emitted by your runtime do not match the keys in your translation files — for example a translation management system that exported pl with only one/other, or a CLDR version skew between your build resolver and your TMS that shifts a few/many boundary. In those cases the symptom is a persistent other fallback even though your code is correct; chase it through CLDR plural category mismatch debugging. For the conceptual model of how every language’s categories are derived, step back up to pluralization rules across languages.

FAQ

Why does my Russian plural look correct for 3 but wrong for 5?

3 falls in the few category (last digit 2–4) and a binary count === 1 ? a : b ternary happens to serve the few string as its default arm, so it looks right by accident. 5 is many, which the ternary has no branch for — it serves the same default string, producing the wrong noun ending. Spot-checking 1 and 3 is exactly why this bug survives review; always test a many value like 5, 11, or 12.

Do I need a polyfill for Arabic and Slavic plural rules?

On Node 13+ and every evergreen browser, no — Intl.PluralRules ships full CLDR data, and both react-intl (v5+) and i18next (v21+) use it directly. On older Node or runtimes with trimmed ICU data, add @formatjs/intl-pluralrules/polyfill plus the per-locale data imports (/locale-data/ar, /locale-data/ru). The polyfill is only about category selection; your catalog must still define the category arms.

Why do i18next’s _few and _many keys never resolve?

Almost always because compatibilityJSON: 'v4' is missing from your init config. Older i18next defaults expanded plurals into numeric suffixes (_0, _1, _2) that don’t map to CLDR category names, so your _few/_many keys are never looked up and every count falls through to _other. Set compatibilityJSON: 'v4' and the suffixes become the CLDR category names.

Part of Pluralization Rules Across Languages.