Developer First ICU MessageFormat Plurals: JS, MF2 & Arkian
Developer First ICU MessageFormat Plurals: JS, MF2 & Arkian

ICU MessageFormat plurals map a numeric variable to a locale-driven plural category, and the engine resolves that mapping in a strict order. Explicit =n matches win first, then the offset value subtracts from the number before the CLDR plural rule for the locale runs. The other category is mandatory in every pattern, acting as the required fallback, and the exact set of categories (zero, one, two, few, many, other) changes depending on the target language.
TL;DR:
- Exact
=nmatches are checked first and override any locale rules, making them essential for predictable plural handling across languages.- Offset values are subtracted only after failing exact matches, influencing category selection and the display of the
#placeholder.- In languages with more than two categories, such as Arabic or Polish, defining all relevant categories and testing with high-category locales prevents grammatical errors.
- Nest plurals inside
selectwhen messages depend on both gender and count, but avoid deep nesting to prevent translator mistakes.- Automated tools like Arkian validate complete full-sentence sub-messages, check for missing
otherclauses, and help prevent common plural localization bugs before shipping.
Table of Contents
- What Are ICU MessageFormat Plurals?
- How Do Exact Matches and Offset Work Together?
- When Should You Nest Plurals Inside Select?
- How Do CLDR Plural Rules Change by Language?
- Practical Code Examples for JavaScript and MessageFormat 2.0
- What’s the Right Way to Test Plural Messages?
- What Mistakes Break Plural Messages in Production?
- Arkian’s Take on Plural-Safe Localization Packaging
- Arkian: Automated Localization Production for Plural-Safe Releases
- Sources
- FAQ
What Are ICU MessageFormat Plurals?
ICU MessageFormat plurals are a pattern syntax that binds a number to one of six CLDR-defined plural categories: zero, one, two, few, many, and other. Each locale defines its own subset of these categories, which is why the same pattern renders differently in English, Arabic, or Polish without changing a line of application code.
The basic shape looks like this:
{count, plural,
one {You have # message.}
other {You have # messages.}
}
Here, count is the input variable, plural tells the formatter to apply plural selection, and each keyword branch (one, other) holds the text for that category. The # inside a branch is replaced by the numeric value, formatted with the locale’s number format.
A few rules govern how this resolves in practice:
otheris not optional. Every plural block needs anotherclause. If the input doesn’t match any defined category, or if a locale simply lacks a category you wrote a branch for, the formatter falls back toother.- English only defines
oneandother. Most English strings only ever need those two branches, which is why so many developers assume plurals are simple, right up until they localize into Russian or Arabic. - Category names are keywords, not labels you invent. You can’t create a custom category like
few_but_not_many. You use only the six CLDR-defined names, and only the ones the target locale actually supports. #respects the locale’s number formatting. A count of 1,200 renders as “1,200” in English and “1.200” in German automatically, because#runs throughNumberFormatbefore substitution.
Skipping other or assuming every language splits plurals the same way ICU’s own documentation warns against is one of the most common bugs in production localization pipelines, and it usually surfaces only after a release ships to a market that uses more than two categories.
How Do Exact Matches and Offset Work Together?
Exact-value matches and offsets solve two different problems, and mixing up their evaluation order is where most plural bugs start. An =n clause, like =0 or =1, always gets checked before any plural category rule runs. Offset, by contrast, only matters once the engine has moved past exact matches and needs to pick a category and format the # placeholder.
The evaluation order works like this:
- The formatter checks for an exact match on the raw input value (
=0,=1,=2, and so on). If one exists, that branch wins immediately, no offset applied. - If no exact match exists, the formatter subtracts the
offsetvalue (if one is set) from the input. - The formatter queries CLDR’s plural rules for the locale using that offset-adjusted number to choose a category (
one,few,other, etc.). - Inside the chosen branch,
#displays the offset-adjusted number, not the raw input.
One number to remember: offset is applied after exact matches, never before. This ordering is exactly what lets you build the classic “You and 2 others” pattern:
{count, plural, offset:1
=0 {You added this}
=1 {You added this and it's just you}
one {You and # other joined}
other {You and # others joined}
}
With count = 3, the exact matches fail, offset subtracts 1, leaving 2, and CLDR’s English rule for 2 selects other, rendering “You and 2 others joined.” Change the locale, and the same pattern can shift categories entirely without touching the offset logic, which is the whole point of separating exact matches from CLDR-driven selection in the MessageFormat v2 guide.

When Should You Nest Plurals Inside Select?
Nest plurals inside select, not the other way around, when a message depends on both a category (like gender) and a count. Putting select on the outside keeps each inner plural block simple and lets translators reorder words naturally within a single grammatical context, instead of untangling two selectors stacked in the wrong order.
A safe nested pattern looks like this:
{gender, select,
male {{count, plural, one {He has # file.} other {He has # files.}}}
female {{count, plural, one {She has # file.} other {She has # files.}}}
other {{count, plural, one {They have # file.} other {They have # files.}}}
}
Keep a few habits in mind when combining selectors:
- Use
selectordinalfor ranking language, like “1st place” or “3rd attempt,” since ordinal categories (1st, 2nd, 3rd, nth) follow different CLDR rules than cardinal plurals and some locales define categories cardinal plurals never use. - Cap nesting at two levels. A
selectwrapping apluralis manageable; aselectwrapping aselectwrapping apluralis where translators start making silent mistakes. - Never split a nested message across multiple keys. Each full sentence needs to live inside one sub-message so word order can shift per language, a point ICU’s own formatting guidance stresses directly.
How Do CLDR Plural Rules Change by Language?
CLDR publishes the plural category rules for every supported locale, and reading those tables before you write a pattern saves you from shipping a message that silently drops half its meaning in another language. English uses two categories: one for exactly 1, other for everything else, including 0 and every fraction.
Arabic uses six: zero, one, two, few, many, and other, each tied to specific number ranges and modulo rules. Slovenian and other Slavic languages add few and many categories that split numbers ending in certain digits from the rest, which means a two-branch English pattern ported blindly into Slovenian will render grammatically wrong text for large swaths of numbers.
A few practical notes when you’re working across locales:
- Always check the CLDR chart for your target locale before assuming
one/othercovers it. Categories are not universal, and skipping this step is the single most common source of plural bugs. - Pass integers when the display value is a whole count, but stringify or format decimals explicitly when the message needs to preserve fractional precision, since plural category selection behaves differently for
1versus1.0in some locales. - Test with at least one high-category locale (Arabic or Polish work well) even if your primary market is English, since it forces every pattern to declare
othercorrectly.
Pro Tip: Pull the actual CLDR plural rules chart for your target locale before writing the pattern, not after a translator flags a bug. It takes two minutes and prevents an entire class of release regressions.
Practical Code Examples for JavaScript and MessageFormat 2.0
A JavaScript implementation using the MessageFormat library compiles a plural pattern once and reuses it for every input value:
const mf = new IntlMessageFormat(
'{count, plural, one {# item} other {# items}}',
'en'
);
mf.format({ count: 0 }); // "0 items"
mf.format({ count: 1 }); // "1 item"
mf.format({ count: 2 }); // "2 items"
mf.format({ count: 5 }); // "5 items"
Adding an offset and exact matches changes the output shape entirely for the same input range:
const notif = new IntlMessageFormat(
`{count, plural, offset:1
=0 {No one liked this}
=1 {Just you liked this}
one {You and # other liked this}
other {You and # others liked this}}`,
'en'
);
| Input count | Matched branch | Output |
|---|---|---|
| 0 | =0 |
“No one liked this” |
| 1 | =1 |
“Just you liked this” |
| 2 | one (offset applied: 1) |
“You and 1 other liked this” |
| 5 | other (offset applied: 4) |
“You and 4 others liked this” |
MessageFormat 2.0 restructures this same logic with explicit variable declarations and a .match/.local syntax designed for cross-runtime consistency, according to the ICU documentation on MF2:
.match {$count :number}
1 {{Just you liked this}}
* {{{$count} people liked this}}
MF2 is a draft specification meant to eventually replace MessageFormat 1.0 across ICU-based runtimes, and it standardizes selection logic that today varies slightly between the ICU4J, ICU4C, and JavaScript implementations. Existing MessageFormat 1.0 patterns keep working, but new projects planning multi-year platform support should track MF2 for compatibility.
What’s the Right Way to Test Plural Messages?
Test plural messages the same way you’d test any conditional logic: hit every branch explicitly, not just the ones your test data happens to trigger. That means writing unit tests for exact-match values (0, 1), boundary counts around offset thresholds, and at least one non-English locale with a richer category set than your source language.
A reasonable test checklist looks like this:
- Exercise every category branch, including
other, with values that actually fall into it, not just the value you wrote the test for. - Test exact-match values separately from plural-rule values to confirm
=0and=1aren’t accidentally shadowed by aonebranch matching first. - Include a high-category locale like Arabic or Polish in automated tests, since English test coverage alone hides most plural bugs.
- Verify decimal handling if your app ever passes fractional counts (ratings, currency, percentages) through a plural pattern.
- Run message validators or linters in CI that catch missing
otherclauses or unbalanced braces before merge, a practice the ICU formatting guide recommends as standard.
Pro Tip: Give translators an editor preview that renders each branch with real sample numbers, not just the raw ICU syntax. Translators catch awkward word order almost instantly when they see rendered output, and almost never when they’re reading {count, plural, one {...} other {...}} cold.
Bringing a translator into the review loop before a pattern ships, rather than after a bug report, cuts down on the kind of fragmented-string errors that plural syntax is specifically designed to prevent.
What Mistakes Break Plural Messages in Production?
Most plural bugs trace back to one habit: treating a sentence like a template to fill in, rather than a unit a translator needs to rewrite wholesale. Fragmenting a message into pieces (“You have”, a plural count, “in your cart”) forces translators to guess at word order that doesn’t exist in their language, and it’s a documented source of broken translations.
Keep these practices in place across a codebase of any size:
- Write full sentences inside each plural branch. Never split a sentence across a plural argument and surrounding concatenated strings.
- Limit nesting to two levels (one
selectorselectordinalwrapping oneplural), and flatten anything deeper into separate messages. - Document the expected numeric type for every plural variable (integer count, currency amount, percentage) in a comment next to the pattern.
- Add context comments for translators explaining what the count actually represents, especially when the same variable name is reused across features.
- Include plural test fixtures in every pull request that touches a message pattern, covering zero, one, a mid-range value, and the locale’s highest category.
| Pitfall | Why it breaks | Fix |
|---|---|---|
| Fragmented sentence pieces | Translators can’t reorder words that live outside the message | Keep full sentences inside each branch |
Missing other clause |
Formatter has no fallback, some locales error or render blank | Always include other, even if it seems redundant |
| Deep nesting (3+ levels) | Translators lose track of which branch applies to which condition | Cap nesting at select wrapping plural |
| Assuming two categories everywhere | Arabic, Polish, and others need more than one/other |
Check the CLDR chart for every target locale |
| No exact-match test coverage | =0/=1 branches silently get shadowed by plural rules |
Test exact values separately from plural-rule values |
Arkian’s Take on Plural-Safe Localization Packaging
Manual plural review doesn’t scale once a small team is shipping to a dozen locales, because someone has to remember to check every other clause, every offset, every exact match, for every language, every release. Arkian automates that validation step directly in its packaging pipeline, checking for full-sentence sub-messages and running basic plural tests before a language file ever reaches a delivery bundle, without needing access to a repository or a full TMS.
That automation doesn’t replace judgment on nesting or translator context. It catches the mechanical failures, missing other, fragmented strings, before they ship.
— Arkian
Arkian: Automated Localization Production for Plural-Safe Releases
Writing correct plural patterns by hand across ten locales is tedious work, and catching a missing other clause after release is worse. Arkian is built for the small team that doesn’t have a dedicated localization engineer checking every CLDR category by hand before each build.

Arkian’s localization strings service generates translated, validated language files automatically, structured to keep full sentences intact inside plural sub-messages rather than fragmenting them across concatenated strings. Paired with multilingual voice production for TTS output, teams get packaged, review-ready assets in formats like JSON, iOS .strings, and Android XML without opening a repository or configuring a TMS. Arkian’s collaboration with the Quiet Harbour app shows this pipeline handling a real multilingual release end to end.
Membership starts at $19.00 CAD per month, or you can run a one-off job with credit packs starting at the Starter tier for $29.00 CAD. Check current plans and pick the tier that matches your next release.
Sources
FAQ
What Is the other Category For?
The other category is the mandatory fallback branch in every ICU plural pattern, used whenever the input number doesn’t match any other defined category. It’s required even in locales like English that mostly rely on one, because ICU’s own specification treats a missing other clause as invalid.
Does offset Apply Before or After Exact Matches?
Offset applies after exact matches, never before. The formatter checks =n clauses against the raw input first, and only subtracts the offset value when it needs to run CLDR plural-rule selection, as detailed in the MessageFormat v2 guide.
What’s the Difference Between plural and selectordinal?
plural selects a category based on cardinal quantity (how many), while selectordinal selects based on rank (1st, 2nd, 3rd). They use different CLDR category sets, so a locale’s ordinal rules won’t necessarily match its cardinal plural rules.
How Does Arkian Handle Plural Errors in Localization Files?
Arkian’s automated packaging pipeline checks for missing other clauses and fragmented sub-messages before generating final localization strings files, without requiring repository access. Pricing for the underlying service isn’t published on a per-feature basis; current membership and one-off pricing is listed on Arkian’s pricing page.
Should I Migrate Existing Patterns to MessageFormat 2.0 Now?
Not urgently. MessageFormat 2.0 is still a draft specification meant to eventually replace version 1.0, and existing ICU MessageFormat patterns continue to work across current libraries, so migration is worth planning but not rushing.