Skip to content
Comvi

Pluralize with ICU

View .md

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.

src/main.tsx
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>,
);
src/Cart.tsx
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:

src/i18n.ts
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 {# товару}}' },
},
});
src/cart.ts
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).

src/locales/uk.json
{
"messages": "{count, plural, one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}"
}
src/inbox.ts
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 form

count 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.

src/locales/en.json
{
"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}}}"
}
src/feed.ts
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.

src/locales/en.json
{
"place": "You finished in {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place"
}
src/race.ts
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.

src/locales/en.json
{
"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}}}}"
}
src/activity.ts
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.

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.