How to test pluralization
Ask ICU which categories the language has, then check the catalogue against that list. new Intl.PluralRules('pl').resolvedOptions().pluralCategories returns few, many, one and other. A Polish file holding only one and other renders 5 produktu, and Polish and Russian disagree about the number 21.
Why check this
English has two plural forms and a test suite written in English reaches for count === 1. That expression cannot express Russian, where 21 takes the singular and 5 does not, and it cannot express Arabic, which has six categories. The translation is present, the test passes, and the interface is ungrammatical for every count except 1 and 2.
Run this when a language is added and whenever a counted string is introduced. The defect it prevents is the one nobody in the office can read: a cart that says the equivalent of "5 item" to every Polish customer while the English screenshots look perfect.
Prerequisites
- Node 22 with full ICU.
node -p "[process.versions.icu, process.versions.cldr].join(' ')"answered78.2 48.0here. A Node built without full ICU carries data for one locale, so every plural result it gives for another language is the fallback rather than an answer. - Three catalogues, one message each.
plurals/en.json:
{ "items": { "one": "{count} item", "other": "{count} items" } }
plurals/pl.json, translated by someone working from the English file and its two forms:
{ "items": { "one": "{count} produkt", "other": "{count} produktu" } }
plurals/ru.json, with all four Russian forms:
{ "items": { "one": "{count} товар", "few": "{count} товара", "many": "{count} товаров", "other": "{count} товара" } }
Steps
- Step 1.
Ask for the categories instead of assuming them. Save as
plural-cats.mjs.for (const l of ['en', 'ja', 'pl', 'ru', 'ar']) { const r = new Intl.PluralRules(l).resolvedOptions(); console.log(`${l.padEnd(3)} cardinal: ${r.pluralCategories.join(', ')}`); } console.log(`en ordinal : ${new Intl.PluralRules('en', { type: 'ordinal' }).resolvedOptions().pluralCategories.join(', ')}`);en cardinal: one, other ja cardinal: other pl cardinal: few, many, one, other ru cardinal: few, many, one, other ar cardinal: few, many, one, two, zero, other en ordinal : few, one, two, otherOne form in Japanese, two in English, four in Polish and Russian, six in Arabic. English alone needs four more categories for ordinals, which is where 1st, 2nd, 3rd and 4th come from.
- Step 2.
Check each catalogue against the list for its own language. Save as
plural-audit.mjs.import { readFileSync } from 'node:fs'; for (const locale of ['en', 'pl', 'ru']) { const file = JSON.parse(readFileSync(`plurals/${locale}.json`, 'utf8')); const need = new Intl.PluralRules(locale).resolvedOptions().pluralCategories; for (const [key, forms] of Object.entries(file)) { const have = Object.keys(forms); const missing = need.filter((c) => !have.includes(c)); const extra = have.filter((c) => !need.includes(c)); console.log( `${locale} ${key}: has [${have.join(', ')}] needs [${need.join(', ')}]` + (missing.length ? ` MISSING ${missing.join(', ')}` : '') + (extra.length ? ` UNUSED ${extra.join(', ')}` : '') ); } }en items: has [one, other] needs [one, other] pl items: has [one, other] needs [few, many, one, other] MISSING few, many ru items: has [one, few, many, other] needs [few, many, one, other]The Polish file has the shape of the English file it was translated from. Half of its counts have nowhere to go.
- Step 3.
Render real counts through both the correct selector and the English-shaped one. Save as
render-plural.mjs.import { readFileSync } from 'node:fs'; const load = (l) => JSON.parse(readFileSync(`plurals/${l}.json`, 'utf8')); const icu = (locale, key, count) => { const forms = load(locale)[key]; return (forms[new Intl.PluralRules(locale).select(count)] ?? forms.other).replace('{count}', count); }; const naive = (locale, key, count) => { const forms = load(locale)[key]; return (count === 1 ? forms.one : forms.other).replace('{count}', count); }; const cat = (l, n) => new Intl.PluralRules(l).select(n); console.log('n pl cat pl render ru cat ru render ru if(n===1)'); for (const n of [1, 2, 5, 21, 22]) { console.log( String(n).padEnd(5) + cat('pl', n).padEnd(8) + icu('pl', 'items', n).padEnd(15) + cat('ru', n).padEnd(8) + icu('ru', 'items', n).padEnd(16) + naive('ru', 'items', n) ); }n pl cat pl render ru cat ru render ru if(n===1) 1 one 1 produkt one 1 товар 1 товар 2 few 2 produktu few 2 товара 2 товара 5 many 5 produktu many 5 товаров 5 товара 21 many 21 produktu one 21 товар 21 товара 22 few 22 produktu few 22 товара 22 товараThree things at once. Polish falls through to
otherfor every count it has no form for, so 2, 5, 21 and 22 all render the same wrong word. Russian with a complete file is right everywhere. Thecount === 1version is wrong at 5 and at 21, and correct at 1, 2 and 22, which is exactly the pattern that makes a reviewer believe it works. - Step 4.
Check the two edges a catalogue audit cannot see. Save as
plural-edge.mjs.const en = new Intl.PluralRules('en'); const en1 = new Intl.PluralRules('en', { minimumFractionDigits: 1 }); const ord = new Intl.PluralRules('en', { type: 'ordinal' }); console.log(`select(1) ${en.select(1)}`); console.log(`select(1) with 1 decimal place ${en1.select(1)}`); console.log(`select('1.0') ${en.select('1.0')}`); console.log(`select(1.5) ${en.select(1.5)}`); console.log(`ordinal categories ${ord.resolvedOptions().pluralCategories.join(', ')}`); console.log(`ordinal 1 2 3 4 11 21 ${[1, 2, 3, 4, 11, 21].map((n) => `${n}:${ord.select(n)}`).join(' ')}`);select(1) one select(1) with 1 decimal place other select('1.0') one select(1.5) other ordinal categories few, one, two, other ordinal 1 2 3 4 11 21 1:one 2:two 3:few 4:other 11:other 21:oneThe same value 1 is
oneorotherdepending on how many decimals it will be displayed with, because English says "1 star" and "1.0 stars". The string'1.0'is coerced to the number 1 and loses that, so the category comes back asone.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| MISSING few, many for a locale | The catalogue was built from the English shape | Send the missing categories for translation before the language ships. |
| Two counts in the same language render the same word | Selection fell through to other | Check for the missing category rather than the string. The fallback hides it. |
| Polish and Russian differ at 21 | Identical category names, different rules behind them | Never copy a mapping between languages. Ask Intl.PluralRules for each one. |
| A count of 0 lands in many | Slavic languages have no zero category, and 0 is not special | Write a separate message for the empty state if the copy needs one. |
| select returns one for a value shown as 1.0 | The formatter has fraction digits the plural rules were not told about | Build PluralRules with the same fraction-digit options as the NumberFormat. |
Common mistakes
What to check next
- How to check for missing translations: the key-level audit this one extends to plural forms.
- How to check the thousand separator and decimal separator: the number next to the counted noun, formatted for the same locale.
- How to test text expansion in translations: plural forms are often the longest strings in a catalogue.
- How to check the browser locale: which locale the selector is being handed at runtime.
FAQ
How many plural forms does a language have?
Ask for them. new Intl.PluralRules(locale).resolvedOptions().pluralCategories returned one form for Japanese, two for English, four for Polish and Russian and six for Arabic in step 1.
Which counts should a pluralization test cover?
At least 0, 1, 2, 5, 11, 21, 101 and one fraction. Those hit every Slavic category and both English ordinal edges. Derive the list from the categories, then assert one count per category.
Does a zero category exist?
In Arabic and Welsh, yes. Polish and Russian put 0 in many. An empty-state message is a separate string, not a plural form, in most languages.
Why does my plural test pass with a locale that has no data?
A Node build without full ICU carries data for one locale only, so other languages fall back to it. Check process.versions.icu before trusting any plural result from CI.
Verified
Verified by Maks Vernynode 22.23.2ICU 78.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny