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 ofzero,one,two,few,many, andothercategories. A fallback tootherforcount=2produces grammatically invalid text. - Slavic Languages (
ru,pl,cs): Splitfewandmanybased on last-digit and last-two-digits rules (e.g.,11–14map tomany,1, 21, 31map toone,2–4, 22–24map tofew). - 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 compileto 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-pluralrulespolyfill versions. Mismatched CLDR versions can cause silent plural boundary shifts. - Instantiate per request in Node.js: Cache
IntlMessageFormatinstances 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,
});
}
});