Skip to content
Chapter 84Lesson 2

ICU MessageFormat: plurals, select, gendered forms

ICU MessageFormat, the Unicode syntax that puts plural, ordinal, and gendered grammar inside one translation string so the catalog, not your component, handles per-language choice.

The last lesson left {count} as a plain named slot in You have {count} unread messages. That holds while the count is always plural, but the moment it can be 1, the grammar breaks: “You have 1 unread messages” is wrong, and every count-shaped string has the same flaw.

The obvious fix is to branch in code:

// The catalog owns the sentence — but the plural is ours to handle, right?
const noun = count === 1 ? t('inbox.message') : t('inbox.messages');
return <p>{t('inbox.unread', { count, noun })}</p>;

This is last lesson’s mistake in a new disguise. There you learned not to concatenate fragments, because translators reorder sentences; here the ternary bakes English’s plural rule, one form for 1 and one for everything else, straight back into the component. English has two plural forms, Russian four, Arabic six, Chinese one. A ternary expresses none of that, and one ternary per language would turn your component into a grammar textbook.

So push the branching into the catalog string, where each language’s rules already live. The string carries the branches, the runtime picks the right one for the current locale, and your call collapses to a single value:

return <p>{t('inbox.unread', { count })}</p>;

That string’s syntax is ICU MessageFormat . This lesson teaches you to read and write it for three kinds of choice, counts, ordinals, and free variants like gender, including the nested case where a notification needs both a person and a count. You will also meet the four ways these strings break, three of them silently.

Last lesson’s catalog held flat sentences with named slots. ICU MessageFormat keeps the same three-party contract, but enriches the catalog value: it stops being a flat sentence and becomes a tiny template the runtime interprets.

Here is the full inbox line:

{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}

When that string renders, the runtime reads count from the object you passed to t(), finds which plural category that number falls into for the current locale, picks the matching branch, and replaces the # token with the locale-formatted number. None of that logic lives in your component; all of it lives in a string the translator can edit.

The “ICU” stands for International Components for Unicode , and its rules come from CLDR . next-intl, the library from last lesson, ships an ICU MessageFormat parser, so you write these strings in the catalog and the library applies them.

ICU can express five kinds of choice:

  • plural for cardinal counts (“3 messages”).
  • selectordinal for ordinals (“3rd place”).
  • select for free string variants (gender, role, notification type).
  • Inline number and date formatting, for currencies, dates, and percentages.

This lesson teaches the first three. Inline number and date exist, but the better move is to format at the seam: call the formatter explicitly and pass the result in. That formatter family, Intl.NumberFormat, Intl.DateTimeFormat, and friends, is the subject of the next lesson.

The plural form does the most work. Here is the inbox message, split into its parts.

{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}

The variable. It matches the key in the object you pass, t('inbox.unread', { count }). Change the count and you change the branch.

{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}

The selector keyword. plural means treat this value as a cardinal number and pick a branch from the locale’s plural categories.

{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}

The branch table: a list of selector {message} arms. Exact matches like =0 are checked first, then CLDR keywords like one and other. other is mandatory, the catch-all when nothing else matches.

{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}

The # token: the count rendered as a locale-aware number, with grouping and decimals applied. It is #, not {count}, a distinction the next section is devoted to.

1 / 1

Your component never changed: the call is still t('inbox.unread', { count }). Every decision about how the string varies with the count lives in the catalog.

# is the formatted count, {count} is a bug

Section titled “# is the formatted count, {count} is a bug”

Inside a plural branch, # stands for the selector value, count, formatted as a locale-aware number. You don’t name the variable again or pass the locale; # carries both, so 12345 renders as 12,345 in en-US and 12 345 in fr-FR.

Write {count} instead and two things break. {count} re-interpolates the raw variable as an ordinary placeholder, so the number prints with no grouping. And because a {count} now sits where # belonged, the easy slip is to print the variable twice: a branch reading {count} {count} messages renders “5 5 messages.” Neither error throws: the page renders a plausible string, so knowing the rule is the only defense.

Predict what the following program prints. One message uses # correctly; a sibling makes the {count} mistake.

Predict what this program prints, then press Check.

The locale is en-US. format(message, values) runs an ICU message the way t() would.

const count = 1234;
// Correct: `#` renders the formatted count.
const right = format('{count, plural, other {# unread messages}}', { count });
// Bug: `{count}` re-prints the raw variable, and it's doubled.
const wrong = format('{count, plural, other {{count} {count} unread messages}}', { count });
console.log(right);
console.log(wrong);

Each language has its own plural categories

Section titled “Each language has its own plural categories”

Look back at the inbox message: it carries three branches, =0, one, and other. The one and other are not arbitrary names. They are plural categories , and English happens to use two of them, not because two is universal. CLDR declares, per language, which categories exist and which numbers fall into each.

The runtime handles the rest. You write one message shape for your source language, the translator supplies the branch set their language needs, and CLDR decides which branch a given number lands in. You never enumerate languages or write a rule. The tabs below show the same inbox message across four languages; watch the branch set change while your call stays identical.

{count, plural, one {# unread message} other {# unread messages}}

2 categories: one (just 1) and other (everything else).

Across all four tabs your call was t('inbox.unread', { count }); only the catalog’s branch set differed. These are cardinal numbers; the next keyword, for positions, branches differently. You don’t memorize the categories; when you need a language’s set, you look it up:

Unicode CLDR — Language Plural Rules
unicode.org

The per-language table of plural categories and the numbers that map to each. Reference, not memorization.

The thing that maps a number to a plural category is a native browser API, Intl.PluralRules, and every ICU MessageFormat implementation calls into it (or a polyfill) to pick the branch.

new Intl.PluralRules('en-US').select(1); // 'one'
new Intl.PluralRules('en-US').select(2); // 'other'
new Intl.PluralRules('ru-RU').select(5); // 'many'

You will rarely call this yourself, since ICU picks the branch for you, but it shows the categories ship in your runtime today, not in a library.

Implement categoryFor so it returns the plural category for a number in a given locale; for Russian, one line of standard JavaScript hands back few and many.

Implement categoryFor(locale, n) so it returns the CLDR plural category for the number — a single Intl.PluralRules call does it. These categories ship in your runtime; this just reads them.

    Reference solution
    function categoryFor(locale, n) {
    return new Intl.PluralRules(locale).select(n);
    }

    Exact matches for wording, keywords for grammar

    Section titled “Exact matches for wording, keywords for grammar”

    Return to that =0 in the inbox message. CLDR categories cover grammar, but sometimes you want different wording for a specific number, and that is a product decision: “No unread messages” reads better than “0 unread messages.” ICU’s exact matches, =0, =1, and =2, handle this. They match a literal value and are tried before the keywords, so =0 wins for exactly zero while one and other handle the rest.

    One rule separates code that works in English from code that works everywhere: reach for the CLDR keyword by default, and use an exact match only when the wording itself changes. =1 for the singular looks safe but breaks the moment a translator localizes it.

    {count, plural, =1 {# unread message} other {# unread messages}}

    The trap. =1 matches only the literal 1, so in a language whose one category also covers 21, 31, and so on, those numbers drop to other and get the wrong form.

    The =0 override is a source-locale product decision; translators inherit the structure and decide whether it fits their language.

    The inbox count is a cardinal: how many. Ranks and positions are a different kind of number, which place, and they branch on a different set of rules. “1st, 2nd, 3rd, 4th” is the English example, and it uses the second selector keyword: selectordinal.

    {rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}

    selectordinal works like plural, with the same # token and the same mandatory other rule, but it consults CLDR’s ordinal rules instead of cardinal ones. The two rule sets disagree:

    n: 1 2 3 4
    cardinal: one other other other → 1, 2, 3, 4 (no suffix logic)
    ordinal: one two few other → 1st, 2nd, 3rd, 4th

    That is why you write “2 messages” but “2nd place.” Other languages differ: German writes “1.”, “2.”, and Japanese uses 番. As before, the translator picks the branches their language needs while you write one t('leaderboard.rank', { rank }). Reach for selectordinal whenever you render ordinal numbers: leaderboards, rankings, positions.

    select for variants the data already carries

    Section titled “select for variants the data already carries”

    The third keyword changes what you branch on. plural and selectordinal branch on a number, using categories the runtime computes from CLDR. select branches on a string, using literal matches you define, and like the others it needs a mandatory other fallback. This is the keyword for variants like gender, notification type, or role.

    Here is the running example for the rest of the lesson, a “liked your post” notification that varies by the actor’s gender:

    {gender, select, male {He liked your post} female {She liked your post} other {They liked your post}}

    The selector is a string, and each branch is a literal match against it. other catches everything you didn’t list, including unknown or unrecorded values. The same shape works for a notification type (invoice, payment, other) or an organization role (admin, member, other).

    One rule separates a correct select from a bug: select renders a distinction the data already carries, never one you infer. The classic mistake is reaching for the user’s name to guess gender. The selector value comes from a column your database actually stores. If your data doesn’t record gender, the message has only an other branch, and “They liked your post” is the correct, complete answer. A German catalog might carry all three branches because German pronouns and nouns inflect, but the data feeding the selector is the same either way.

    Real notifications combine both dimensions. “He has 3 new messages” needs a person and a count, so it needs a select and a plural. ICU composes them by nesting: a select whose every branch contains a complete plural message.

    {gender, select,
    male {{count, plural, one {He has # new message} other {He has # new messages}}}
    female {{count, plural, one {She has # new message} other {She has # new messages}}}
    other {{count, plural, one {They have # new message} other {They have # new messages}}}}

    The outer selector: the same select on gender from the last section, with branches male, female, and other.

    {gender, select,
    male {{count, plural, one {He has # new message} other {He has # new messages}}}
    female {{count, plural, one {She has # new message} other {She has # new messages}}}
    other {{count, plural, one {They have # new message} other {They have # new messages}}}}

    Each branch’s body is itself a complete message. The male arm is not a string but a whole plural on count.

    {gender, select,
    male {{count, plural, one {He has # new message} other {He has # new messages}}}
    female {{count, plural, one {She has # new message} other {She has # new messages}}}
    other {{count, plural, one {They have # new message} other {They have # new messages}}}}

    Inside, the familiar plural branch table: one and other, picked by the count’s CLDR category. The outer select chose the pronoun; the inner plural chooses the noun form.

    {gender, select,
    male {{count, plural, one {He has # new message} other {He has # new messages}}}
    female {{count, plural, one {She has # new message} other {She has # new messages}}}
    other {{count, plural, one {They have # new message} other {They have # new messages}}}}

    Two levels deep, # still means the formatted count, locale-formatted exactly as before.

    1 / 1

    When you nest, put the lower-cardinality dimension on the outside. Here gender has three values and the plural multiplies inside each, so select (gender) wraps plural (count). The heuristic is whether the translator can follow the structure, and the coarser cut on the outside usually reads best. The call stays as flat as ever, two values and one key:

    return <p>{t('notification.newMessages', { gender, count })}</p>;

    Even two levels deep, the translator edits only the text in the innermost arms, “He has # new message,” “She has # new messages,” never the structure.

    You can read these strings now; authoring them is its own skill, because three of the four common mistakes are silent.

    1. other is mandatory. Leave it out and the parser throws when it loads the message, a real and loud error. This is the one mistake the tooling catches for you; the other three don’t throw.
    2. Use # for the count inside plural branches, never {count}. Get it wrong and you ship “5 5 messages” with no error in sight.
    3. CLDR keyword by default, exact match only for wording overrides. =1 in place of one works in English and quietly drops 21, 31, and so on to the wrong branch in other languages.
    4. select needs the data to carry the value. Branch on a column your database stores; never infer the distinction from a name.

    Two smaller hazards round out the set. ICU treats {, }, and ' as syntax, so a literal apostrophe or brace must be escaped; JSON editors don’t enforce ICU’s rules, so validate a catalog through the actual runtime, not by reading it. And some libraries that advertise “ICU support” skip selectordinal or select; next-intl covers all three, but if you swap libraries, confirm against the runtime rather than the marketing.

    Now complete a message yourself. Each blank has exactly one form that is correct across all languages, not just English.

    Complete the ICU message. Each blank has exactly one form that's correct across every language — not just English. Pick the right option from each dropdown, then press Check.

    {gender, ___, male {{count, ___, one {He has ___ new message} ___ {He has # new messages}}} female {...} other {...}}

    One more term to recognize in the wild. Unicode is finalizing MessageFormat 2 (MF2) , a cleaner syntax with explicit declarations and built-in formatter integration. As of 2026, most i18n libraries, next-intl included, still target MF1, the syntax you just learned, and the eventual migration will be mechanical. You only need to recognize the name when it comes up.