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

{ "items": { "one": "{count} item", "other": "{count} items" } }
{ "items": { "one": "{count} produkt", "other": "{count} produktu" } }
{ "items": { "one": "{count} товар", "few": "{count} товара", "many": "{count} товаров", "other": "{count} товара" } }

Steps

  1. 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, other

    One 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.

  2. 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.

  3. 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 other for 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. The count === 1 version 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.

  4. 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:one

    The same value 1 is one or other depending 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 as one.

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

Sign: A Slavic translation is grammatical at 1 and 2 and wrong at 5.Cause: The catalogue holds one and other because the English source had two forms. Polish needs few and many as well. In step 3 every Polish count except 1 renders produktu, including 5 and 21, and no key is missing anywhere, so a coverage report shows the language fully translated.
Sign: A plural mapping copied from a Russian locale file is wrong in Polish.Cause: Both languages have the same four category names, and their rules differ. Step 3 shows 21 as one in Russian and many in Polish. A shared helper that hardcodes the Russian mapping is wrong for Polish at exactly the counts a reviewer is least likely to try.
Sign: A rating that reads 1.0 stars is pluralized as a singular.Cause: Intl.PluralRules coerces its argument to a number, so select('1.0') returns one, as step 4 shows. The number of fraction digits is part of the rule in English. Pass minimumFractionDigits to PluralRules, matching the NumberFormat that renders the value.
Sign: Ordinals are built with a suffix table and break at 11 and 21.Cause: English ordinals have four categories of their own, and 11th is other while 21st is one. Step 4 prints the mapping. A lookup keyed on the last digit produces 11st, which is the version most hand-written helpers emit.

What to check next

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.

intermediate9 minpublished updated Maks Verny