Pluralize with ICU
Write {count, plural, …} in a message and let t() pick the form the locale needs. The
only addition to the Quick start instance is one constructor option;
nothing here needs the platform.
Compose the ICU compiler
Section titled “Compose the ICU compiler”import { createRoot } from 'react-dom/client';import { createI18n, icuCompiler, I18nProvider } from '@comvi/react';import App from './App';
const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', compiler: icuCompiler, messages: { en: { 'cart.items': '{count, plural, =0 {Your cart is empty} one {# item} other {# items}}' }, uk: { 'cart.items': '{count, plural, one {# товар} few {# товари} many {# товарів} other {# товару}}' }, },});
createRoot(document.getElementById('root')!).render( <I18nProvider i18n={i18n}> <App /> </I18nProvider>,);import { useI18n } from '@comvi/react';
export function Cart({ count }: { count: number }) { const { t } = useI18n(); return <p>{t('cart.items', { count })}</p>;}compiler: icuCompiler is a constructor option, so it covers the inline catalog and every
catalog a loader or the CDN adds later. @comvi/react, @comvi/vue, @comvi/svelte,
@comvi/solid, @comvi/next, @comvi/next/client and @comvi/next/server re-export
icuCompiler; @comvi/nuxt takes the comvi.icu module option instead. Outside a framework, @comvi/core alone works the same way:
import { createI18n } from '@comvi/core';import { icuCompiler } from '@comvi/core/icu';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', compiler: icuCompiler, messages: { en: { 'cart.items': '{count, plural, =0 {Your cart is empty} one {# item} other {# items}}' }, uk: { 'cart.items': '{count, plural, one {# товар} few {# товари} many {# товарів} other {# товару}}' }, },});import { i18n } from './i18n';
await i18n.init();
i18n.t('cart.items', { count: 0 }); // "Your cart is empty"i18n.t('cart.items', { count: 1 }); // "1 item"i18n.t('cart.items', { count: 42 }); // "42 items"Without compiler: icuCompiler, an ICU message ({count, plural, …}) fails in development at
ingestion, before anything renders. Inline, it throws E_ICU_SYNTAX at the call that carries the
catalog; over a loader it fails that namespace’s load instead: E_ICU_SYNTAX reaches
onLoadError, the namespace stays empty, and when that was the only namespace init() rejects
with [@comvi/core] Failed to load all namespaces for locale "…": … (E_ALL_NAMESPACES_FAILED in
production) rather than with E_ICU_SYNTAX. In production it
renders the message text literally and reports the error through onError (console.error when
no handler is set).
Plural
Section titled “Plural”{ "messages": "{count, plural, one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}"}import { i18n } from './i18n';
await i18n.setLocale('uk');
i18n.t('messages', { count: 1 }); // "1 повідомлення"i18n.t('messages', { count: 5 }); // "5 повідомлень"i18n.t('messages', { count: 22 }); // "22 повідомлення", the few formcount goes to Intl.PluralRules, the matching branch is chosen, and # becomes the number.
Which categories a language uses is a CLDR rule, so let the translator of each locale define
its branches:
| Category | Used by |
|---|---|
zero |
Arabic, Latvian, Welsh |
one |
English, German, French, most languages |
two |
Arabic, Hebrew, Slovenian |
few |
Ukrainian and Polish (2 to 4), Czech |
many |
Ukrainian and Polish (5 to 20), Arabic |
other |
Every language, and required in every message |
An exact match such as =0 or =1 is checked before the categories, so =1 beats one.
Use =0 for “no items”: English has no zero category, so a zero branch never matches
there. A count === 1 ? singular : plural in code works in English and is wrong in Ukrainian,
Polish and Arabic.
Select
Section titled “Select”{ "user.greeting": "{gender, select, male {He left a comment} female {She left a comment} other {They left a comment}}", "order.status": "{status, select, pending {Processing} shipped {On its way} delivered {Delivered} other {Order status: {status}}}"}import { i18n } from './i18n';
i18n.t('user.greeting', { gender: 'female' }); // "She left a comment"i18n.t('user.greeting', { gender: 'unknown' }); // "They left a comment"i18n.t('order.status', { status: 'shipped' }); // "On its way"select picks a branch by string value, and other is the branch for any value with no
match. It fits any enumeration: gender, formality of address, an order status, a role.
Ordinals
Section titled “Ordinals”{ "place": "You finished in {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place"}import { i18n } from './i18n';
i18n.t('place', { position: 1 }); // "You finished in 1st place"i18n.t('place', { position: 2 }); // "You finished in 2nd place"i18n.t('place', { position: 113 }); // "You finished in 113th place"selectordinal asks Intl.PluralRules with type: 'ordinal', so the same category names
mean something else: in English one is 1st, 21st and 31st, two is 2nd and 22nd, few is
3rd and 23rd, and other covers the rest, 11th to 13th included. A locale with no ordinal
distinctions uses other alone, and a message that spells the English branches still renders
there because other catches every value.
Nested messages
Section titled “Nested messages”{ "activity": "{gender, select, female {{count, plural, one {She wrote # comment} other {She wrote # comments}}} other {{count, plural, one {They wrote # comment} other {They wrote # comments}}}}"}import { i18n } from './i18n';
i18n.t('activity', { gender: 'female', count: 3 }); // "She wrote 3 comments"plural and select nest in either order. Deep nesting is hard on translators; split a
message with more than two levels into separate keys.
How it works
Section titled “How it works”The compiler is the part of the parser that decides what a braced argument means. The default
compiler, the one the root export ships, knows {name} and nothing else, so a message with a
comma inside the braces has no meaning to it. @comvi/core/icu is a separate subpath whose
only export is icuCompiler, which adds plural, selectordinal and select. An app whose
catalog has no such message never loads the ICU parser, and an app that does names the compiler
once, in the constructor, where it applies to every catalog the instance ever receives. That
is why ICU is opt-in and why the option has no ordering rule with .use(): the constructor
reads it before any loader runs.
Without the compiler, an ICU message is a parse error. In development the catalog is checked
at ingestion, and the check walks every branch and every tag’s children, so a broken nested
message fails where the catalog entered rather than in the first component that renders it.
Inline, that is a throw with the code E_ICU_SYNTAX at the call that carries the catalog; over
a loader it fails that namespace’s load and reaches onLoadError. In production the message
renders with its braces as literal text and E_ICU_SYNTAX is reported once per compilation
through onError, or to console.error when no handler is set. The same error covers an
argument type the compiler does not implement: {price, number} is an E_ICU_SYNTAX, not a
parameter named price, number. Format the value in code and pass it as a plain {param}.
With the compiler, each {count, plural, …} is parsed once per template. The choice list is
split into a selector-to-branch map, each branch’s text is kept verbatim, and every =N
selector is rewritten to the canonical spelling of its number, so =1.0 and =01 are the same
branch as =1. The parsed form is cached, so the cost is paid on the first call and the branch
text is compiled only when it is chosen. At render the compiler checks the exact selectors
first, then asks Intl.PluralRules for the category the locale assigns to count and takes
the branch with that name, falling through to other. # inside the chosen branch is replaced
by String(count) with no number formatting, so a message that shows large counts formats the
number separately and passes it under a second name.
The locale that drives Intl.PluralRules is the one the message was found in, not the current
one. A key served from the fallback chain uses the fallback locale’s categories, so an English
fallback for a Ukrainian key pluralizes as English, which is the correct grammar for the text
that is on screen. Every message needs an other branch because that is the only category
every locale has: when neither an exact selector nor the category matches and there is no
other, the argument renders as an empty string. A count that is not a number renders the
whole argument literally, with a console warning in a development build and nothing logged in
production.
Read next
Section titled “Read next”- ICU reference: what the compiler does not implement, apostrophe quoting and the exact
E_ICU_SYNTAXtexts. - Format numbers, dates and currency: numbers, dates and currency, which are functions rather than ICU skeletons.
- Type-check translation keys: parameter types derived from the ICU arguments in your catalog.