Autore:
    Creazione:2026-09-02Ultimo aggiornamento:2026-09-02

    ICU Message Format: the syntax, and the parts that trip people up

    ICU MessageFormat is a string syntax that lets a translation contain its own branching logic: plurals, gendered forms, number and date formatting. It exists because grammar belongs to the translator, not to the developer writing if (count === 1). This post covers the syntax, the language-dependent parts that break naive implementations, and how the JS ecosystem handles it.

    Table of Contents

    The problem, concretely

    Here is the code almost everyone writes first:

    ts
    const label = count + " " + (count === 1 ? t("item") : t("items"));
    

    This works in English and breaks everywhere else:

    • Russian and Polish need three or four forms, not two.
    • Japanese needs one, and the space you concatenated is wrong.
    • Arabic needs six, and the number itself should be rendered in the locale's numbering system.
    • French puts a non-breaking space before some punctuation, which your + " " just destroyed.

    The deeper problem is that the sentence has been cut into fragments. A translator sees item and items with no context and no ability to reorder the sentence. ICU MessageFormat fixes this by keeping the whole sentence in one translatable string and giving the translator the branching operators.

    Simple arguments

    The smallest unit is a placeholder in single braces:

    text
    Hello, {name}!
    

    You pass { name: "Alice" } at format time and get Hello, Alice!. Braces are the only special characters; to print a literal brace you wrap it in single quotes: '{'.

    That is the entire "interpolation" feature. Everything else in ICU is built on top of it.

    Plural

    plural selects a branch based on a number:

    text
    {count, plural,
      one {You have one unread message}
      other {You have # unread messages}
    }
    

    Three things to know:

    • # is replaced by the formatted value of count, locale-formatted, so 1234 becomes 1,234 in en-US and 1 234 in fr-FR.
    • other is mandatory. Every ICU implementation will throw or fail validation without it. It is the fallback when no category matches.
    • =0, =1, … match exact values and are checked before the CLDR categories. Use them for special-cased copy ("No messages"), not as a substitute for one.
    text
    {count, plural,
      =0 {No unread messages}
      one {One unread message}
      other {# unread messages}
    }
    

    offset

    offset:n subtracts n from the value before both category selection and # substitution. It exists for the "Alice and 3 others liked this" pattern:

    text
    {count, plural, offset:1
      =0 {No one liked this}
      =1 {{name} liked this}
      one {{name} and one other liked this}
      other {{name} and # others liked this}
    }
    

    With count: 4, the # renders 3. offset is genuinely useful and genuinely under-supported, so check your runtime before relying on it.

    Plural categories are language-dependent

    This is the part people get wrong. The category names zero, one, two, few, many, other are not universal buckets you fill in for every language. Each locale uses a subset, defined by the CLDR plural rules, and the rules are grammatical, not intuitive.

    Language Tag Categories used Count
    Japanese ja other 1
    Chinese zh other 1
    English en one, other 2
    German de one, other 2
    French fr one, many, other 3
    Czech cs one, few, many, other 4
    Polish pl one, few, many, other 4
    Russian ru one, few, many, other 4
    Arabic ar zero, one, two, few, many, other 6
    Welsh cy zero, one, two, few, many, other 6

    Two consequences that surprise people:

    • one does not mean "1". In Russian, one covers 1, 21, 31, 101: any number ending in 1 except those ending in 11. In French, 0 falls into one.
    • Adding a category to the English source does nothing. The English message only needs one and other; the Polish translation needs four branches, and that structure lives in the Polish string, not the English one. Any format that forces all locales to share one key shape will fight you here.

    You can check what a runtime actually does without installing anything:

    ts
    new Intl.PluralRules("pl").select(2); // "few"
    new Intl.PluralRules("pl").select(5); // "many"
    new Intl.PluralRules("ru").select(21); // "one"
    new Intl.PluralRules("ar").select(0); // "zero"
    

    Intl.PluralRules ships CLDR data in every modern browser and in Node. A library claiming CLDR pluralization is almost always calling it underneath.

    select and selectordinal

    select branches on an arbitrary string: a gender, a role, a status, a plan tier.

    text
    {gender, select,
      female {She updated her profile}
      male {He updated his profile}
      other {They updated their profile}
    }
    

    Keys are matched literally and other is mandatory here too. select is the right tool any time sentence structure depends on an enum value, because languages disagree about which enums affect grammar.

    selectordinal is the same shape as plural but uses the ordinal plural rules, which are a different table from the cardinal ones:

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

    English uses four ordinal categories (1st, 2nd, 3rd, 4th) even though it only uses two cardinal ones. That asymmetry is exactly why the two operators are separate.

    Number, date and time arguments

    ICU can format the value it interpolates:

    text
    Total: {price, number, currency}
    Published {publishedAt, date, long} at {publishedAt, time, short}
    Conversion: {rate, number, percent}
    

    The modern form is the skeleton, introduced with ICU 60 and marked by a :: prefix. Skeletons are far more expressive than the legacy style names:

    text
    {price, number, ::currency/EUR}
    {value, number, ::percent scale/100}
    {amount, number, ::compact-short}
    {distance, number, ::unit/kilometer unit-width-narrow}
    

    Skeleton support is the most uneven part of the ecosystem. FormatJS implements them; several other runtimes only accept the legacy number, currency / date, long forms. Verify :: support in your actual runtime before shipping.

    Nesting, and where it stops being readable

    ICU composes. A plural branch can contain a select, which can contain another plural:

    text
    {hostGender, select,
      female {{guestCount, plural, offset:1
        =0 {{host} does not give a party}
        =1 {{host} invites {guest} to her party}
        other {{host} invites {guest} and # other people to her party}
      }}
      other {{guestCount, plural, offset:1
        =0 {{host} does not give a party}
        other {{host} invites {guest} and # other people to their party}
      }}
    }
    

    This is the canonical ICU example and also the canonical argument against deep nesting. Two levels is where translators start making brace errors and where TMS editors stop helping. Nest at most two levels; if you need a third, split the sentence into two messages.

    How JS libraries handle ICU

    Library ICU support What you actually write
    react-intl (FormatJS) Native, full ICU strings, including skeletons and rich-text tags
    next-intl Native ICU strings, via FormatJS's intl-messageformat
    i18next Plugin required key_one / key_other suffix keys and {{name}}; ICU via i18next-icu
    vue-i18n Partial / its own {name} interpolation and pipe-separated plural branches
    Angular ($localize) Subset ICU plural / select inside templates, extracted to XLIFF

    A few notes so the table is not misleading:

    • i18next's default syntax is not ICU and is not worse for it. Suffix keys (item_one, item_few) map onto Intl.PluralRules categories and are arguably easier for translators to edit in flat JSON. But select and nested branching are not part of it, so you either add i18next-icu or you write the logic in code.
    • vue-i18n's pipe plurals use a per-locale rule function, not CLDR categories by default. That works, but the plural rule lives in your app config rather than in the data.
    • FormatJS is the reference implementation in JS. When people say "ICU MessageFormat" in a JS context, they usually mean what FormatJS accepts.

    How Intlayer handles it

    Intlayer does not use a string DSL. The branching operators are functions in a content declaration file, so the structure is typed and each locale declares only the categories its grammar needs:

    **/*.content.ts
    import { plural, t, type Dictionary } from "intlayer";
    
    const openingsContent = {
      key: "total_openings",
      content: {
        totalOpenings: t({
          en: plural({
            one: "{{count}} opening",
            other: "{{count}} openings",
          }),
          pl: plural({
            one: "{{count}} oferta",
            few: "{{count}} oferty",
            many: "{{count}} ofert",
            other: "{{count}} ofert",
          }),
        }),
      },
    } satisfies Dictionary;
    
    export default openingsContent;
    
    **/*.tsx
    const { totalOpenings } = useIntlayer("total_openings");
    
    totalOpenings(5); // Polish locale → "5 ofert"
    

    The mapping to ICU concepts is direct:

    ICU construct Intlayer
    {name} insert("Hello {{name}}"), or auto-detected
    {count, plural, …} plural({ one, few, many, other })
    {value, select, …} select({ draft, published, fallback })
    gender branch of select gender({ male, female, fallback })
    boolean branch of select cond({ true, false })
    numeric ranges (non-CLDR) enu({ "0": …, ">5": …, fallback: … })
    {n, number, ::currency/EUR} useCurrency()(1234.5, { currency: "EUR" })

    plural delegates category selection to Intl.PluralRules, so the CLDR table above applies unchanged. Formatting stays separate: numbers, dates, currencies and lists go through the formatter hooks instead of being embedded in the message.

    Honest limits:

    • Intlayer requires a build step; the compiler extracts declarations at build time. If you want plain JSON loaded at runtime, that is a different model.
    • plural cannot nest a t() inside its branches yet; you wrap plural in t(), not the other way round.
    • The ecosystem is smaller than i18next's. Fewer TMS integrations, fewer StackOverflow answers.

    If you are coming from a codebase that already contains real ICU strings, the react-intl compat adapter parses them directly: plural, select, selectordinal, #, and the legacy number / date / time arguments. Skeletons and offset: are not covered by that resolver, so check those messages when you migrate. The i18next adapter resolves the suffix form (key_one, key_male) against Intl.PluralRules instead.

    Common mistakes

    • Hardcoding plural logic in JS. count === 1 ? a : b produces the wrong output for 8 of the 10 languages in the table above. Once the ternary is in code, no translator can fix it.
    • Concatenating translated fragments. Word order, agreement and punctuation spacing are all locale-dependent. Keep the sentence whole.
    • Omitting other. It is required by the spec, not a convention. Most parsers reject the message; the ones that do not will render nothing.
    • Assuming your categories generalize. An English source with one / other does not mean the Polish file has two branches. Let each locale declare its own. See per-locale content declaration.
    • Using =1 where you meant one. =1 matches only the literal 1. In Russian, 21 needs one, and =1 will never fire for it.
    • Putting # outside a plural branch. It is only special inside plural / selectordinal. Elsewhere it is a literal hash.
    • Forgetting that # is formatted. If you want the raw number, interpolate the argument by name instead.

    Going further

    Commenti

    Ancora nessun commento. Sii il primo a condividere i tuoi pensieri.

    Articoli correlati

    Ultimi articoli