Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.
I18nMedium

Handle plural forms with Intl.PluralRules or ICU MessageFormat

Select the correct grammatical plural category for every language using Intl.PluralRules or an ICU-aware i18n library instead of simple singular/plural branching.

Utilities
Quick take
Typical fix time 25 min
  • English has 2 plural forms but Arabic has 6 and Russian has 3
  • Never concatenate a count with a string like '"You have " + count + " items"'
  • Use Intl.PluralRules to select the right message key per locale
  • ICU MessageFormat in react-intl or i18next handles plural selection automatically
Why it matters: Pluralization rules vary dramatically across languages — what works as a simple singular/plural branch in English produces grammatically wrong output in Arabic, Russian, Polish, and dozens of other languages. Shipping incorrect plurals signals low translation quality and breaks trust with native speakers in target markets.

Rule Details

Every language follows rules about how nouns change form depending on the quantity being described. English uses two forms — one for exactly 1 (singular) and one for everything else (plural). Many other languages use three, four, or six distinct forms. The Unicode CLDR project catalogues these rules and assigns each language a set of named plural categories.

Code Examples

Intl.PluralRules accepts a locale and returns the CLDR category for a given count. Use it to select the right pre-translated string:

// pluralize.ts
 
/**
 * Return the CLDR plural category for a count in a given locale.
 * The returned key maps to one of the message variants in your
 * translation file (e.g. messages.items.one, messages.items.other).
 */
export function getPluralCategory(
  count: number,
  locale: string
): Intl.LDMLPluralRule {
  return new Intl.PluralRules(locale).select(count);
}
 
// Usage example
const locale = 'ru'; // Russian
const messages = {
  items: {
    one:   '{count} элемент',
    few:   '{count} элемента',
    many:  '{count} элементов',
    other: '{count} элемента', // decimals
  },
};
 
function formatItemCount(count: number, locale: string): string {
  const category = getPluralCategory(count, locale);
  const template = messages.items[category] ?? messages.items.other;
  return template.replace('{count}', String(count));
}
 
formatItemCount(1,  'ru'); // "1 элемент"
formatItemCount(3,  'ru'); // "3 элемента"
formatItemCount(11, 'ru'); // "11 элементов"
formatItemCount(21, 'ru'); // "21 элемент"
Cache PluralRules instances

Constructing a new Intl.PluralRules object on every call is expensive. Cache instances by locale in a Map to avoid allocating them repeatedly inside render loops or message formatting functions.

// Cached version
const cache = new Map<string, Intl.PluralRules>();
 
function getPluralRules(locale: string): Intl.PluralRules {
  if (!cache.has(locale)) {
    cache.set(locale, new Intl.PluralRules(locale));
  }
  return cache.get(locale)!;
}

Why It Matters

Pluralization rules vary dramatically across languages — what works as a simple singular/plural branch in English produces grammatically wrong output in Arabic, Russian, Polish, and dozens of other languages. Shipping incorrect plurals signals low translation quality and breaks trust with native speakers in target markets.

CLDR Plural Categories

The CLDR defines six category names used by Intl.PluralRules: zero, one, two, few, many, and other. Every language uses a subset of these categories:

LanguageCategories usedExample counts
Englishone, other1 → one; 0, 2–∞ → other
Germanone, other1 → one; 0, 2–∞ → other
Frenchone, other0, 1 → one; 2–∞ → other
Russianone, few, many, other1, 21, 31 → one; 2–4, 22–24 → few; 5–20, 25–30 → many; decimals → other
Polishone, few, many, other1 → one; 2–4 (not 12–14) → few; 5–21... → many
Arabiczero, one, two, few, many, otherAll six categories, complex modulo rules
JapaneseotherOnly one form for all counts
Czechone, few, many, otherAnimate vs. inanimate gender affects selection

A binary count === 1 ? singular : plural check works only for English and a handful of similar languages. For Arabic, it produces the wrong grammatical form for five of the six possible counts.

ICU MessageFormat with react-intl

react-intl (opens in new tab) uses ICU MessageFormat syntax, which handles plural selection declaratively inside the message string itself. Define all plural variants in the translation file and let the library pick the right one:

// en.json
{
  "inbox.messageCount": "{count, plural, one {You have # message} other {You have # messages}}",
  "cart.itemCount": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"
}
// ar.json (Arabic — all 6 categories required)
{
  "inbox.messageCount": "{count, plural, zero {ليس لديك رسائل} one {لديك رسالة واحدة} two {لديك رسالتان} few {لديك # رسائل} many {لديك # رسالة} other {لديك # رسالة}}"
}
// InboxCount.tsx
import { useIntl } from 'react-intl';
 
interface Props {
  count: number;
}
 
export function InboxCount({ count }: Props) {
  const intl = useIntl();
 
  return (
    <p>
      {intl.formatMessage(
        { id: 'inbox.messageCount' },
        { count }
      )}
    </p>
  );
}

The # token inside the plural block is replaced with the formatted count. The library selects the block whose key matches the CLDR category for the current locale and count.

Ordinal Plural Forms

Intl.PluralRules also handles ordinal numbers (1st, 2nd, 3rd) via the type: 'ordinal' option:

const ordinal = new Intl.PluralRules('en-US', { type: 'ordinal' });
 
const suffixes: Record<Intl.LDMLPluralRule, string> = {
  one:   'st',
  two:   'nd',
  few:   'rd',
  other: 'th',
  zero:  'th', // not used in English ordinals
  many:  'th', // not used in English ordinals
};
 
function formatOrdinal(n: number): string {
  const category = ordinal.select(n);
  return `${n}${suffixes[category]}`;
}
 
formatOrdinal(1);  // "1st"
formatOrdinal(2);  // "2nd"
formatOrdinal(3);  // "3rd"
formatOrdinal(4);  // "4th"
formatOrdinal(21); // "21st"

Pluralization in i18next

i18next resolves plural keys by appending a suffix derived from Intl.PluralRules. Configure the i18next instance to use the built-in intlPlurals plugin and define suffixed keys in your translation JSON:

// i18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
 
i18next
  .use(initReactI18next)
  .init({
    lng: 'en',
    resources: {
      en: {
        translation: {
          // i18next v4+ uses _one / _other suffixes by default
          itemCount_one:   'You have {{count}} item',
          itemCount_other: 'You have {{count}} items',
        },
      },
      ar: {
        translation: {
          // Arabic needs all 6 suffixes: _zero _one _two _few _many _other
          itemCount_zero:  'ليس لديك عناصر',
          itemCount_one:   'لديك عنصر واحد',
          itemCount_two:   'لديك عنصران',
          itemCount_few:   'لديك {{count}} عناصر',
          itemCount_many:  'لديك {{count}} عنصرًا',
          itemCount_other: 'لديك {{count}} عنصر',
        },
      },
    },
  });
// Usage in a component
import { useTranslation } from 'react-i18next';
 
function ItemCount({ count }: { count: number }) {
  const { t } = useTranslation();
  // i18next selects the correct _suffix key automatically
  return <span>{t('itemCount', { count })}</span>;
}
Missing plural keys cause silent fallbacks

If a translation file omits a required plural key for its locale (e.g. Arabic is missing _many), most i18n libraries silently fall back to _other. The resulting output is grammatically wrong and the bug is invisible in English development environments. Validate plural key completeness as part of your CI translation checks.

Anti-Patterns

// ❌ Binary branch — wrong for Arabic, Russian, Polish, and many others
const label = count === 1 ? 'item' : 'items';
 
// ❌ String concatenation — untranslatable word order
const message = 'You have ' + count + ' new messages';
 
// ❌ Hardcoded English grammar suffix
const suffix = count === 1 ? '' : 's';
const label = `${count} message${suffix}`;
 
// ✅ Let Intl.PluralRules select the right pre-translated form
const category = new Intl.PluralRules(locale).select(count);
const label = translations[category].replace('{count}', String(count));
 
// ✅ Or use ICU syntax in your i18n library
// "{count, plural, one {# message} other {# messages}}"

Standards

  • Use these references as the standard for the rendered internationalization behavior, not just the source strings or config.
  • Check the implementation against MDN: Intl.PluralRules before treating the rule as satisfied.
  • Check the implementation against Unicode CLDR Plural Rules before treating the rule as satisfied.

Verification

Automated Checks

  • Search the codebase for string concatenation patterns involving counts — any count + " item", template literals with count variables, or ternary count === 1 ? checks that are not delegated to an i18n library.
  • Add a CI lint step or custom ESLint rule that flags template literals and string concatenation involving variables named count, total, or length inside i18n-aware files.

Manual Checks

  • For each supported locale beyond English, open the translation file and confirm that all plural category keys required by that locale's CLDR rules are present.
  • Render components that display counts in a Storybook story or test with locale='ar' (Arabic) and values of 0, 1, 2, 5, and 11 to exercise all six CLDR categories.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Find all places in this codebase where a count value is concatenated with a string or where only singular/plural branching is used, without Intl.PluralRules or ICU plural syntax.

Fix

Auto-fix issues

Replace count string concatenation and binary singular/plural logic with Intl.PluralRules-based key selection or ICU MessageFormat plural syntax so that all CLDR plural categories are covered for every target locale.

Explain

Learn more

Explain why languages have different numbers of plural forms, what CLDR plural categories are, and how Intl.PluralRules and ICU MessageFormat solve pluralization correctly across locales.

Review

Code review

Review translation files and components that render counts. Flag any strings that concatenate a number with text, any ternary that picks only between "singular" and "plural", and any i18next or react-intl message that is missing plural keys required by the target locale.

Sources

References used to support the guidance in this rule.

Rules that often go hand-in-hand with this one.

Use Intl APIs for currency, number, and date formatting

Format monetary values, numbers, and dates using the browser's built-in Intl.NumberFormat and Intl.DateTimeFormat APIs instead of manual string manipulation.

I18n
Design UI components to accommodate text expansion from translation

Ensure that layouts use flexible sizing so that translated text — which can be 30–50% longer than English — does not overflow, clip, or break the UI.

I18n
Write internationalisation-friendly translation strings

Translation strings use message format patterns (ICU or similar) rather than string concatenation, and correctly handle pluralisation, gender, and variable interpolation.

JavaScript
Place list items within list containers

List item elements (li) must always be direct children of a list container (ul, ol, or menu) to maintain valid HTML structure and correct screen reader announcements.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385