Debugging CLDR Plural Category Mismatches
A CLDR plural category mismatch is when the category your translation provides and the category the runtime actually selects for a given number disagree — so a Polish string with only one/other arms keeps rendering other while the few/many arms you expected never fire. The runtime never throws; Intl.PluralRules('pl').select(5) returns "many", your catalog has no many key, and the formatter silently falls through to other. This page shows how to verify what categories a locale needs with Intl.PluralRules, why stale CLDR data shifts the boundaries, and how to reconcile the two sides so every count hits a defined arm.
This is the failure mode behind so many “the translation is there but the plural is still wrong” tickets, and it sits one layer below the broader pluralization rules across languages model. The categories themselves are correct CLDR; the bug is that two systems — your catalog and your selector — disagree about which subset of categories the locale uses.
many arm the catalog never defined, so the count silently resolves to other — a category mismatch, not a missing key error.Root cause: two category sets that have to agree
Every plural lookup has two independent sources of truth that must produce the same category name for the same number. The first is the selector: a CLDR-backed engine (Intl.PluralRules, or the plural arm resolution inside ICU MessageFormat) that maps a number to one of zero, one, two, few, many, other per the rules in Unicode TR35, Part 4. The second is your catalog: the set of category arms a translator actually authored. A mismatch is any number where the selector returns a category your catalog does not define — or, less common but just as broken, where your catalog defines an arm the selector will never emit for that locale.
Three things drive these two sets apart:
- The translation omits a required category. A TMS or a hand-written file exports Polish with only
one/other(the English shape) because whoever seeded it did not knowpluses four categories. Everyfew/manycount falls through toother. - The translation provides a category the locale never selects. Someone copies a Russian template into Japanese;
jaonly ever selectsother, so theone/few/manyarms are dead code that no number reaches. Harmless to output, but it hides real coverage gaps in lint reports. - Stale or skewed CLDR data. The selector’s category boundaries are versioned. If your build resolver ships CLDR 41 and your TMS validated against CLDR 44, a boundary can move — Russian fractional handling and several
few/manyedges have shifted across releases — so the arm that was correct at export time is now selected for a different number.
The reason this is invisible is that the other arm is mandatory and always defined, so a mismatch degrades to “wrong grammatical form” rather than “missing key crash”. No exception, no console warning in most setups — just 5 plik(s) instead of 5 plików.
Minimal reproducible example
The smallest reproduction is a catalog whose arms are a strict subset of what the selector emits:
import IntlMessageFormat from 'intl-messageformat';
// Polish catalog seeded with the English category shape (one/other only)
const msg = new IntlMessageFormat(
'{count, plural, one {# plik} other {# plików}}',
'pl',
);
const pr = new Intl.PluralRules('pl');
// What the SELECTOR thinks vs what the CATALOG can serve:
console.log(pr.select(2), msg.format({ count: 2 }));
// "few" "2 plików" ← selector wants `few`, catalog has none → other arm
console.log(pr.select(5), msg.format({ count: 5 }));
// "many" "5 plików" ← selector wants `many`, catalog has none → other arm
pr.select(2) returns "few" and pr.select(5) returns "many", but the catalog defines neither arm, so both numbers fall through to other. The output is not an error — it is a real, grammatically wrong Polish string. That is the entire bug: the selector and the catalog disagree about the category set, and the fallback masks it.
The fix: align the catalog to the selector’s category set
Do not guess which arms a locale needs — ask the selector. Intl.PluralRules.prototype.resolvedOptions().pluralCategories returns the exact list of categories the engine will ever emit for that locale, which is the authoritative spec for what your catalog must cover.
// 1. Ask the runtime which categories THIS locale actually uses.
const cats = new Intl.PluralRules('pl').resolvedOptions().pluralCategories;
// ['one', 'few', 'many', 'other'] ← the catalog must define exactly these
// 2. Author one arm per required category — no more, no fewer.
const fixed = new IntlMessageFormat(
'{count, plural, ' +
'one {# plik} ' + // n = 1
'few {# pliki} ' + // last digit 2-4, not 12-14 (2, 3, 24)
'many {# plików} ' + // the Slavic "many" bucket (5, 12, 25, 112)
'other {# pliku}}', // fractions; required arm, also the safety net
'pl',
);
fixed.format({ count: 2 }); // "2 pliki" (few)
fixed.format({ count: 5 }); // "5 plików" (many)
For the omission case, generate the required arm list from pluralCategories and diff it against the keys present in each locale file — that diff IS your defect list. For the extra-category case (an arm the selector never emits), the same diff flags arms that are not in pluralCategories; delete them so they stop inflating coverage. The same category names drive ICU plural arms and i18next’s suffixed keys (_one, _few, _many, _other), so this alignment is the concrete remedy for handling pluralization in Arabic and Slavic languages and for authoring ICU MessageFormat syntax for complex plurals without dead or missing arms.
Verification snippet
Assert the two category sets are equal — required (from the selector) versus present (from the catalog) — and fail on any difference in either direction:
import { test, expect } from 'vitest';
// The arms each locale catalog actually defines.
const catalogArms: Record<string, string[]> = {
pl: ['one', 'few', 'many', 'other'],
ja: ['other'],
};
test.each(Object.keys(catalogArms))('plural categories match for %s', (locale) => {
const required = new Intl.PluralRules(locale)
.resolvedOptions().pluralCategories;
const present = catalogArms[locale];
const missing = required.filter((c) => !present.includes(c)); // selector needs, catalog lacks
const extra = present.filter((c) => !required.includes(c)); // catalog has, selector never emits
expect(missing, `missing arms in ${locale}`).toEqual([]);
expect(extra, `dead arms in ${locale}`).toEqual([]);
});
Run this for every locale in CI. A quick interactive cross-check confirms the boundary the engine draws for a specific number:
const pr = new Intl.PluralRules('pl');
console.log(pr.select(12)); // "many" — count 12 needs the many arm, not few
console.log(pr.resolvedOptions().pluralCategories); // ['one','few','many','other']
Pin the CLDR version explicitly so the boundary the test asserts is the boundary that ships. Print process.versions.icu (Node) in CI and gate on it; if a runtime upgrade bumps ICU, re-run the matrix before trusting old fixtures.
When to escalate
This fix resolves mismatches in catalogs and runtimes you control. It is insufficient when the divergence is a genuine CLDR version skew you cannot collocate — for example a managed TMS that validates exports against a newer CLDR than your production runtime’s bundled ICU, so an arm is correct in the platform and wrong at runtime (or vice versa). The tell is that resolvedOptions().pluralCategories differs between your build machine and a deployed environment for the same locale. When that happens, freeze both sides to the same ICU/CLDR release or add a polyfill (@formatjs/intl-pluralrules) so selection is deterministic regardless of the host. For the underlying model of how each language’s category set is derived, step back up to pluralization rules across languages; if the mismatch is really a missing-string problem, the graceful fallback chains for missing strings path is the better lens.
FAQ
How do I know which plural categories a locale actually needs?
Call new Intl.PluralRules(locale).resolvedOptions().pluralCategories. It returns the exact array of category names the engine will ever emit for that locale — for example ['one','few','many','other'] for Polish and ['other'] for Japanese. That array is the authoritative spec for which arms your catalog must define; anything missing is a guaranteed other fallthrough, and anything extra is dead code that never resolves.
Why does few or many never fire even though I defined the arm?
Two common causes. Either your runtime’s selector disagrees with the number you are testing — verify with Intl.PluralRules(locale).select(n) to see the category it actually returns — or there is a CLDR version skew between your build resolver and your TMS that moved the boundary, so the arm is selected for different integers than you assumed. Pin the ICU/CLDR version on both sides and re-derive the boundaries from the selector, not from memory.
Is a category mismatch ever an error I can catch?
Usually not. The other arm is mandatory and always defined, so a missing few/many arm degrades to the wrong grammatical form rather than throwing. To turn the silent fallback into a hard failure, diff the selector’s pluralCategories against your catalog’s keys in CI and fail the build on any difference — that converts an invisible runtime mis-render into a deterministic test failure.
Related
- Handling pluralization in Arabic and Slavic languages — the per-language category maps your catalog has to satisfy.
- Pluralization rules across languages — the full model behind every category set.
- ICU MessageFormat syntax for complex plurals — authoring one
pluralarm per CLDR category. - Setting up graceful fallback chains for missing strings — when the real problem is a missing string rather than a mismatched category.
Part of Pluralization Rules Across Languages.