ICU Message Format in Practice: Plurals, Select and the Bugs They Prevent
How ICU message format handles plurals, gender and nesting, with accurate CLDR plural categories, common pitfalls, and which JS libraries really support it.
Somewhere in your codebase there is a line that builds a sentence out of parts: a count, a ternary for the "s", and two string literals glued around them. It has worked fine for years, because your team ships in English and English plurals are forgiving. Translate that line into Russian and the glue comes apart, because Russian does not have two plural forms, it has four, and none of them can be bolted onto a string assembled this way. The fix is not a cleverer ternary. It's writing the message differently from the start.
Why concatenation breaks translation
Here's the pattern, in roughly the shape it shows up in a pull request:
const text = `You have ${count} new message${count === 1 ? '' : 's'}`;
This looks harmless because English pluralisation really is just "add an s or don't." A translator handed this string in isolation sees You have {count} new message{s} and has nowhere to put the Russian plural logic, because the logic lives in your JavaScript, not in the string. Even if they could edit the code, the fix doesn't generalise: Polish needs one wording for counts ending in 2 to 4, a different one for most other counts, and the boundary depends on whether the number ends in 12 to 14. No ternary written for English anticipates that.
The deeper problem is that concatenation treats a sentence as fragments assembled at render time, and fragments don't survive translation. Word order changes between languages. Grammatical gender changes which article or verb ending is correct. Plural forms aren't a suffix, they're a different word. None of this is visible if you only ever read the fragments in your source language, because English happens to need almost no agreement at all.
ICU message format exists to put all of that logic back into the string, where a translator can see it and change it, instead of in code where only a developer can.
The four building blocks
An ICU message is a single string containing arguments. The simplest kind just substitutes a value:
Hello, {name}!
Plural picks a branch based on a number, using CLDR's plural categories, and # inside a branch is replaced with the formatted number itself:
{count, plural, one {# item} other {# items}}
You can also match an exact number before falling back to the categories, which is how "zero state" copy usually gets written:
{count, plural, =0 {No items yet} one {# item} other {# items}}
Select picks a branch based on an arbitrary string value rather than a number, most often gender, and other is not optional. It's the branch used when the value doesn't match any named case, and every select block must have one or the format is invalid:
{gender, select, male {He replied} female {She replied} other {They replied}}
Selectordinal is plural's cousin for ordinal numbers – first, second, third – and uses the same category names with ordinal rules behind them:
{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
For English that correctly produces 1st, 2nd, 3rd, 4th, 21st, 22nd, 23rd and, less obviously, 11th, 12th, 13th as other rather than one/two/few – English ordinals have an exception for the teens that cardinal plurals don't.
Nesting, and the rule of full sentences
These four blocks nest inside each other, which is how you handle a sentence that varies on two axes at once – gender and count, say:
{gender, select,
male {He added {count, plural, one {# photo} other {# photos}}}
female {She added {count, plural, one {# photo} other {# photos}}}
other {They added {count, plural, one {# photo} other {# photos}}}
}
Notice the repetition: the plural block is written out fully inside every branch of the select, rather than factored out and glued back together. That's deliberate, and it's the single rule worth remembering above everything else in this article: put a full sentence inside each branch, not a sentence fragment. It's tempting to write something like {gender, select, male {He} female {She} other {They}} added {count} photos to avoid repeating the plural logic, but that's exactly the concatenation problem again, just moved one level up – a language that reorders the verb relative to the subject, or inflects the verb by gender, has no way to express that across a boundary your template has hard-coded. Repetition in the source is a small cost. A translator unable to produce a grammatical sentence is not.
CLDR plural categories, properly
English trains people to think of plurals as a two-way split, and that intuition actively misleads once you leave it. The Unicode CLDR defines up to six plural categories – zero, one, two, few, many, other – and which ones a given language actually uses, and the numeric rule for each, varies enormously. other is the only category every language defines, which is why it's mandatory.
English uses two: one for exactly 1, other for everything else, including 0 and all fractions. Nothing about 0 is special in English – "0 items", not "0 item".
Russian uses four. one applies to numbers ending in 1, except those ending in 11 (so 1, 21, 31 – but not 11). few applies to numbers ending in 2 to 4, except those ending in 12 to 14 (so 2, 3, 4, 22, 23, 24 – but not 12, 13, 14). many covers numbers ending in 0, or ending in 5 to 9, or ending in 11 to 14 (so 0, 5, 10, 11, 20, 25 all land here). other is reserved for fractional numbers. "1 файл", "2 файла", "5 файлов" are three genuinely different words, not one word with different suffixes.
Polish also uses four, with a boundary that trips people up specifically because it's close to Russian's without being identical. one is exactly 1. few is numbers ending in 2 to 4, except those ending in 12 to 14 – 2, 3, 4, 22, 23, 24, but not 12 to 14. many is everything else that isn't a fraction: 0, 5 to 21, and anything ending in 0, 1, 5 to 9, or 12 to 14. The detail worth internalising is that 22 and 25 are not the same category in Polish – 22 is few ("22 pliki") and 25 is many ("25 plików") – even though they look like they belong to the same tens-group to an English speaker scanning the list. Validate against the real rule, not against a pattern you eyeballed from a few examples.
Arabic uses all six categories. zero is exactly 0, one is exactly 1, two is exactly 2 – each genuinely distinct words, not a shared plural stem. few is any number whose last two digits fall between 3 and 10 – so 3 to 10, 103 to 110, 203 to 210, and so on. many is any number whose last two digits fall between 11 and 99 – 11 to 99, 111 to 199, and so on. other is what's left over: numbers like 100, 101, 102, 200, 1000, and fractions. It is entirely legitimate for a translator to write the same wording for two or three of these categories when Arabic grammar doesn't force a distinct form at that point – the category exists to give the grammar somewhere to go if it needs it, not as a promise that every language will fill all six slots with different text.
Japanese uses exactly one category, other, for every number including 0 and 1. There is no plural inflection to encode, so a Japanese string is the same regardless of count – {count} 件 works unchanged whether count is 1 or 100.
French is the interesting edge case, and current CLDR data treats it differently from how most people learned it. French uses one for both 0 and 1 – "0 fichier", "1 fichier" are grammatically singular in French, unlike English where 0 takes the plural. A many category was added more recently for round multiples of a million in compact or scientific notation (an artefact of how French spells out exact millions), which is a genuinely obscure corner almost no UI string will ever reach. other covers everything from 2 upward. For everyday purposes, treat French as one (0 and 1) and other (2 and up), and don't be surprised if a linter flags a third category you never trigger.
The practical upshot: never hand-roll which categories a language "should" have. Pull the live list at runtime with Intl.PluralRules – new Intl.PluralRules('pl').resolvedOptions().pluralCategories returns exactly the categories above for Polish – and validate translated messages against that list rather than against the shape of your source string.
The apostrophe quirk
ICU message format treats a straight apostrophe as a special character in one specific circumstance: directly in front of a syntax character – {, }, or # inside a plural body – it starts a quoted span that runs until the next apostrophe. Most apostrophes in ordinary prose are nowhere near a brace and render exactly as typed. The gotcha shows up in languages that elide an article right up against a placeholder. A French translator writing d'{name} (meaning "of" plus the placeholder) has put an apostrophe directly before a brace, and depending on the implementation, that can be read as the start of a quoted literal rather than as punctuation – the placeholder silently disappears from the rendered output, or renders as literal text.
The reliable fix is to double the apostrophe whenever it sits immediately next to a brace: d''{name} renders as a literal apostrophe followed by the interpolated value. It's worth putting a note in your translator guidelines about this specifically, because it's invisible in testing with languages that don't elide articles this way, and it tends to surface for the first time in production, in French or Italian, months after launch.
Numbers and dates, briefly
ICU message format also formats numbers and dates inline, using the same brace syntax with a type and an optional style:
{price, number, ::currency/USD}
{updatedAt, date, medium}
These defer to the host environment's number and date formatting (in JavaScript, effectively Intl.NumberFormat and Intl.DateTimeFormat under the hood), so the same message renders "$1,234.00" or "1 234,00 $US" depending on locale without the translator touching the number itself. It's a small feature compared with plural and select, but it's the reason you rarely see a hand-formatted date next to a properly pluralised count in the same codebase – once the plumbing is in place for one, the other comes for free.
Which JS libraries actually speak ICU
This is where claims get sloppy, so it's worth being precise. FormatJS, and react-intl built on top of it, parse and render ICU message format natively – formatMessage takes exactly the syntax described above, and intl-messageformat is the standalone parser if you want it outside React.
i18next does not use ICU syntax by default. It has its own pluralisation mechanism: a key like item gets suffixed variants – item_one, item_other, and so on for languages with more categories – selected by i18next based on the count you pass in, with the suffixes resolved against Intl.PluralRules under the hood. That's a perfectly good system, but it is not ICU message format, and a string like {count, plural, one {# item} other {# items}} won't parse correctly inside an i18next value unless you add the i18next-icu plugin, which swaps in an ICU-based formatter.
vue-i18n has its own pipe-delimited plural syntax instead of ICU: 'no bananas | one banana | {n} bananas', with forms separated by | and selected positionally. It supports custom pluralisation rules for languages that need more than the default few forms, but out of the box it is not parsing ICU either.
Knowing which camp your framework is in matters before you write a single plural string, because an ICU snippet pasted into an i18next or vue-i18n project will show up to users as literal, un-rendered syntax rather than a polite error.
Where ICU validation earns its keep
Plural and select syntax is easy to get subtly wrong – a missing other, an unbalanced brace, a category that doesn't exist in the target language – and the failure mode is ugly: either the build breaks, or worse, it doesn't, and a user sees raw {count, plural, ...} syntax on a production screen. Catching that before a translator even opens the string is worth more than catching it in review.
Translatize's label editor helps at exactly that point. It inserts ready-made plural, select, number and date templates and validates the ICU syntax of the result, so a translator starts from a well-formed message instead of typing nested braces from memory, and a broken one shows up in the editor rather than in a build or on a production screen. Each label also carries a description and context alongside the value, so a translator working on a select block knows gender refers to the subject of the sentence and not, say, a product category – the same ambiguity that causes concatenation bugs in code causes mistranslations in isolation. And because every label has a review status, a plural or select block that's syntactically valid but still reads awkwardly in a given language has somewhere to be caught before it reaches approved.
Before you ship a pluralised string
A short list worth keeping near your i18n setup:
- Never build a count-dependent sentence by concatenating strings in application code. Use ICU plural (or your library's equivalent) from the first line.
- Write full sentences inside every branch of a nested
select/plural, never shared fragments. - Validate a translated message's branches against
Intl.PluralRulesfor that locale, not against the number of branches in your source language. - Double an apostrophe that sits directly before a brace.
- Know, for the specific library you're using, whether
{count, plural, ...}syntax is native or needs a plugin – and if your stack is React withreact-intlor Vue, the two guides linked here cover the framework-specific wiring in more depth. If you're feeding plural strings through AI translation, translating JSON files with AI covers how to validate the branch set a model produces per language.
