ICU Message Format Syntax for Complex Plurals

1. The Problem: Implicit Plural Fallbacks in High-Context Locales

When engineering teams rely on simplified =0, =1, other patterns, they inadvertently break localization for languages with intricate grammatical number systems. Establishing a robust Core i18n Architecture & Locale Negotiation strategy is the prerequisite for handling these edge cases without introducing silent data corruption, UI truncation, or grammatical inaccuracies in production environments. Legacy fallbacks assume a binary or ternary plural model, which fails catastrophically when deployed to locales with morphological complexity.

CLDR Plural Category Mismatches

  • Arabic (ar): Requires explicit handling of zero, one, two, few, many, and other categories. A fallback to other for count=2 produces grammatically invalid text.
  • Slavic Languages (ru, pl, cs): Split few and many based on last-digit and last-two-digits rules (e.g., 11–14 map to many, 1, 21, 31 map to one, 2–4, 22–24 map to few).
  • Defaulting to other: Causes severe grammatical errors in target locales, increases support ticket volume, and produces incorrect screen reader pronunciation.

2. Solution Architecture: Explicit ICU Plural Syntax Mapping

The correct implementation requires explicit category declaration using the plural keyword. By referencing the comprehensive ICU Message Format Deep Dive, teams can structure messages that dynamically resolve to the exact CLDR category required by the active locale, ensuring grammatical accuracy across all supported regions while maintaining a single source of truth for translation files.

Syntax Structure & Category Resolution

Standard ICU plural syntax maps directly to CLDR plural rules. Runtime resolution relies on the native Intl.PluralRules API, which evaluates numeric input against locale-specific thresholds.

{count, plural,
  =0{No items found}
  one{# item found}
  few{# items found (few)}
  many{# items found (many)}
  other{# items found}
}

Implementation Rules:

  • Always declare categories explicitly. Omitted categories default to other, which is a known anti-pattern for high-context locales.
  • The # token is automatically replaced with the formatted numeric value.
  • Avoid hardcoded numeric offsets (e.g., count - 1) for complex morphological rules; let the formatter handle boundary evaluation.

3. Framework-Specific Configuration & Implementation

Implementation varies by stack but strictly adheres to the ICU standard. React ecosystems use react-intl with dynamic IntlProvider locale binding, Vue uses vue-i18n pluralization rule overrides, and Angular relies on @angular/localize extraction pipelines. Node.js backends must instantiate intl-messageformat with explicit locale parameters to guarantee consistent server-side rendering and avoid hydration mismatches.

React & Vue Integration Patterns

React (react-intl) Configure strict fallbacks and pass locale dynamically via context to prevent formatter degradation during locale switches.

import { IntlProvider } from 'react-intl';

export const AppIntlProvider: React.FC<{ locale: string; messages: Record<string, string> }> = ({
  locale,
  messages,
}) => {
  return (
    <IntlProvider
      locale={locale}
      messages={messages}
      defaultLocale="en"
      onError={(err) => {
        if (err.code === 'MISSING_TRANSLATION') {
          console.error(`[i18n] Missing plural key: ${err.message}`);
        }
      }}
    >
      {/* App Tree */}
    </IntlProvider>
  );
};

Vue (vue-i18n v9+) Vue’s ICU mode handles standard CLDR resolution automatically. Override pluralizationRules only when business logic genuinely diverges from CLDR.

import { createI18n } from 'vue-i18n';

const i18n = createI18n({
  legacy: false, // Composition API mode
  locale: 'ar',
  fallbackLocale: 'en',
  missingWarn: false,
  fallbackWarn: false,
  pluralizationRules: {
    // Custom override: only needed when CLDR defaults are insufficient
    ar: (choice) => {
      if (choice === 0) return 0; // zero
      if (choice === 1) return 1; // one
      if (choice === 2) return 2; // two
      const mod100 = choice % 100;
      if (mod100 >= 3 && mod100 <= 10) return 3; // few
      if (mod100 >= 11 && mod100 <= 99) return 4; // many
      return 5; // other
    },
  },
});

Note: pluralizationRules in vue-i18n v9 maps to index positions in an array of translation variants, not named CLDR category strings. When using ICU message format mode (messageResolver with ICU backend), the library handles CLDR categories directly — custom rules are only needed for the legacy array-based format.

Backend & SSR Considerations

  • Pre-compile messages: Use @formatjs/cli compile to compile ICU strings into AST objects during build time. This reduces runtime parsing overhead significantly.
  • Validate CLDR data versions: Ensure staging and production environments use identical @formatjs/intl-pluralrules polyfill versions. Mismatched CLDR versions can cause silent plural boundary shifts.
  • Instantiate per request in Node.js: Cache IntlMessageFormat instances by locale+message key, but never share a single instance across requests if locale state can bleed.
import { IntlMessageFormat } from 'intl-messageformat';

// SSR Handler
export async function renderPage(req, res) {
  const locale = req.headers['accept-language']?.split(',')[0] || 'en';

  // Instantiate formatter per locale/message combination
  const formatter = new IntlMessageFormat(messages[locale]['cart.items'], locale);
  const html = formatter.format({ count: Number(req.query.items) || 0 });

  res.send(html);
}

4. Debugging Steps & Pipeline Validation

Debugging complex plurals requires isolating formatter output from UI rendering layers. Use Intl.PluralRules directly in the console to verify category resolution boundaries, run eslint-plugin-formatjs to catch missing categories during static analysis, and implement automated snapshot testing that iterates through locale-specific plural thresholds before merging pull requests.

Runtime Tracing & Static Linting

Console Boundary Verification

// Run in browser console or Node REPL
const pr = new Intl.PluralRules('ar', { type: 'cardinal' });
[0, 1, 2, 3, 11, 100, 1000].forEach((n) => {
  console.log(`Count: ${n} -> Category: ${pr.select(n)}`);
});
// Expected: 0→zero, 1→one, 2→two, 3→few, 11→many, 100→other, 1000→other

Static Analysis Configuration (eslint-plugin-formatjs)

{
  "plugins": ["formatjs"],
  "rules": {
    "formatjs/no-multiple-whitespaces": "error",
    "formatjs/enforce-plural-rules": [
      "error",
      {
        "one": true,
        "other": true
      }
    ]
  }
}

Automated Coverage Audits

Generate Locale-Specific Plural Boundary Test Matrices

// Jest/Vitest test runner for plural coverage
import { createIntl } from 'react-intl';
import messages from './locales/ar.json';

describe('Plural Category Coverage', () => {
  const intl = createIntl({ locale: 'ar', messages });

  const testCases = [
    { count: 0, expectedCategory: 'zero' },
    { count: 1, expectedCategory: 'one' },
    { count: 2, expectedCategory: 'two' },
    { count: 5, expectedCategory: 'few' },
    { count: 15, expectedCategory: 'many' },
    { count: 100, expectedCategory: 'other' },
  ];

  const pr = new Intl.PluralRules('ar', { type: 'cardinal' });

  testCases.forEach(({ count, expectedCategory }) => {
    it(`resolves count=${count} to ${expectedCategory}`, () => {
      expect(pr.select(count)).toBe(expectedCategory);
    });
  });
});

Production Telemetry Monitoring

window.addEventListener('error', (e) => {
  if (
    e.message?.includes('MISSING_TRANSLATION') ||
    e.message?.includes('INVALID_VALUE')
  ) {
    analytics.track('i18n_plural_error', {
      locale: navigator.language,
      error: e.message,
    });
  }
});