Plurals, ordinals and genders
A ternary written in your language cannot produce the forms another language needs. French
has two plural forms; Russian and Polish have four, Arabic six. count === 1 ? "1 article" : "{count} articles" gives two branches, so Russian gets a grammatically wrong sentence,
and nothing tells you.
So you do not write the branches. You write one form, with {count} where the number
goes, and pass the number as count. The model writes the forms each language needs — the
primary language included — and the SDK picks the right one for the number.
Plurals: count
- React
- React (string)
- Node.js
import { I18nKeylessText } from "i18n-keyless-react";
function CartLine({ items }: { items: number }) {
return (
<I18nKeylessText count={items}>
{"{count} articles dans votre panier"}
</I18nKeylessText>
);
}
// docs-check: skip `count` ships in the next SDK release; remove this marker after the docs bump
import { useTranslation } from "i18n-keyless-react";
function CartBadge({ items }: { items: number }) {
const label = useTranslation("{count} articles dans votre panier", { count: items });
return <span aria-label={label}>{items}</span>;
}
// docs-check: skip `count` ships in the next SDK release; remove this marker after the docs bump
import { awaitForTranslationOrFallbackToOriginal } from "i18n-keyless-node";
const line = await awaitForTranslationOrFallbackToOriginal(
"{count} articles dans votre panier",
user.lang,
{ count: cart.length },
);
What the reader sees:
count | French (primary) | Russian |
|---|---|---|
| 1 | 1 article dans votre panier | 1 товар в корзине |
| 3 | 3 articles dans votre panier | 3 товара в корзине |
| 5 | 5 articles dans votre panier | 5 товаров в корзине |
| 21 | 21 articles dans votre panier | 21 товар в корзине |
You never wrote the Russian few and many branches, and you never wrote "1 article"
either: the primary language gets its own forms from the model too, so a key rendered with
count is looked up in every language, the primary one included.
How it works
The API stores, for each language, one ICU MessageFormat message instead of a plain sentence:
{count, plural, one {{count} товар в корзине} few {{count} товара в корзине} many {{count} товаров в корзине} other {{count} товара в корзине}}
It asks the model for exactly the CLDR plural categories of that language and checks
the answer against Intl.PluralRules before storing it: a Russian message without a few
branch is asked again, not saved. On your side the SDK picks the branch with the same
Intl.PluralRules — the tables every browser and Node ship — and fills {count}. Nothing
to install, no plural table in your bundle.
{count} is filled like a replace placeholder. Your own replace map
wins if it names {count}, so a formatted number goes through unchanged:
<I18nKeylessText count={total} replace={{ "{count}": total.toLocaleString(lang) }}>
{"{count} articles"}
</I18nKeylessText>
The row's key stays the source text, {count} articles dans votre panier. In the
dashboard the cell shows the whole message, one branch per form, and you can edit any
branch by hand like any other translation.
Ordinals: count + ordinal
A rank (1st, 2nd, 3rd — 1er, 2e) follows other rules than a quantity. English has four
ordinal forms, French two, Russian one. Add ordinal:
<I18nKeylessText count={rank} ordinal>{"Vous êtes {count}e"}</I18nKeylessText>
count | English | French |
|---|---|---|
| 1 | You are 1st | Vous êtes 1er |
| 2 | You are 2nd | Vous êtes 2e |
| 3 | You are 3rd | Vous êtes 3e |
| 11 | You are 11th | Vous êtes 11e |
| 22 | You are 22nd | Vous êtes 22e |
Genders and other closed choices: select
A gender, a role, a day of the week: a closed set of values that changes the wording.
Pass the current value in select. The model writes one variant of the sentence per value,
in every language, with the agreement that language needs:
<I18nKeylessText select={{ gender: user.gender }}>Il est connecté</I18nKeylessText>
gender | French | Polish |
|---|---|---|
male | Il est connecté | Jest zalogowany |
female | Elle est connectée | Jest zalogowana |
| anything else | Connecté | Ta osoba jest zalogowana |
The row is written with the values it has seen. The first render with gender: "female"
creates the female branch; a later render with a value the row never saw sends it to
the API, which rewrites every language with the new branch, and the row renders its
neutral other branch until then. Values are identifiers (male, she_her,
admin), at most 10 per variable and 2 variables per sentence. A select and a count
can be combined: the plural is nested inside each variant.
What stays a replace: names and other open values
A user's name is not a closed set. No library declines an unknown proper noun in Russian,
i18next included: the sentence is translated, then the name is pasted in. That does not
change here — but the model is asked to place such a placeholder where the language needs
no inflection, and to rephrase the sentence around it when it does. Bienvenue {name} is
Добро пожаловать, {name} (nominative); Un message de {name} becomes Сообщение от пользователя {name}, where the declension lands on "пользователя", never on the name.
When the value really has to be inflected, the whole sentence with the value in it is the key — the user generated content flow — at one translation per distinct value. Use it for rare cases only.
Older SDKs and the other ports
A row upgraded to plural forms by one app keeps its forms for every other client of the
project. An SDK that does not send count receives the message and renders its other
branch — the form you wrote — with {count} filled by replace as before. The Laravel,
Rails, Flutter, Python, Go, Swift and Kotlin ports do not render the forms yet: they return
the message as stored.