# Pluralize with ICU

Turn ICU on with compiler: icuCompiler, then write plural, select and selectordinal messages that pick the right form for every locale.

Canonical: https://comvi.io/docs/i18n/pluralization

Write `{count, plural, …}` in a message and let `t()` pick the form the locale needs. The
only addition to the [Quick start](/docs/i18n/quick-start) instance is one constructor option;
nothing here needs the platform.

## Compose the ICU compiler

`src/main.tsx`

```tsx

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>,
);
```

`src/Cart.tsx`

```tsx

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`

```ts

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`

```ts

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

`src/locales/uk.json`

```json
{
"messages": "{count, plural, one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}"
}
```

`src/inbox.ts`

```ts

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.

## Select

`src/locales/en.json`

```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`

```ts

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

`src/locales/en.json`

```json
{
"place": "You finished in {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place"
}
```

`src/race.ts`

```ts

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

`src/locales/en.json`

```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`

```ts

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

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

- [ICU reference](/docs/i18n/icu-reference): what the compiler does not implement, apostrophe quoting and the exact `E_ICU_SYNTAX` texts.
- [Format numbers, dates and currency](/docs/i18n/formatting): numbers, dates and currency, which are functions rather than ICU skeletons.
- [Type-check translation keys](/docs/i18n/type-safety): parameter types derived from the ICU arguments in your catalog.
