Why pluralization is not a simple condition
The first instinct when handling plural text is to write something like:
const message = count === 1 ? 'One item' : `${count} items`;This works for English. It fails for Polish, where "2 items" is 2 elementy, "5 items" is
5 elementów, and "21 items" is 21 elementów again. It fails for Arabic, which has six plural
forms. It fails for Russian, which distinguishes between one item, two-to-four items, and five-or-more
items. It fails for zero. In French, zero takes the singular form (0 élément), while in English
zero takes the plural form (0 items).
The core insight is that plural selection is a function of both the language and the number. Every
major language has rules documented in the Unicode CLDR (Common Locale Data Repository) project, and those rules map any integer to one of five named categories: zero, one, two, few, many, and other. Every language uses a subset of these categories, and other is always the
mandatory catch-all.
Spraxium's pluralization engine delegates category selection to Intl.PluralRules, the V8 engine built into Node.js that implements the CLDR rules, and then looks up the keyed variant from the flat translation map.
The naming convention for plural variants
Plural keys follow the pattern baseKey_category. The base key is the same key you would use for
t(). The category is one of the six CLDR names appended with an underscore separator.
{
"items": {
"_zero": "No items in your inventory",
"_one": "One item in your inventory",
"_other": "{{count}} items in your inventory"
}
}After flattening with reference name commands and file name items, the full keys become:
commands.items._zerocommands.items._onecommands.items._other
The engine constructs the lookup key by appending _ plus the resolved category to the base key
you pass to tp().
The six categories and when each applies
| Category | Applies to | Example languages and rules |
|---|---|---|
| zero | Specific zero form | Arabic (0), Welsh (0) |
| one | Singular | English (1), Spanish (1), Portuguese (1) |
| two | Dual form | Arabic (2), Welsh (2), Hebrew (2) |
| few | Paucal / small plural | Russian (-), Polish (2, 3, 4 not ending in teen), Arabic (-0) |
| many | Large plural or irregular | Russian (5+, 1-4), Polish (5+), Arabic (1-9) |
| other | General plural, mandatory catch-all | English (0, 2+), Chinese (all), Japanese (all) |
Languages that do not have a dedicated zero form (most Western European languages) map zero to
other. This means English 0 items resolves to _other, which is why the zero variant in the
example above says "No items." You need to provide a _zero entry specifically for that phrasing.
The resolution algorithm
Category determination
Check whether a custom plural rule has been registered for the requested locale in the pluralRules config field. If a custom rule exists, run that function with the numeric count and use the returned category string. If no custom rule is registered, delegate to Intl.PluralRules(locale).select(count), which implements the Unicode CLDR rules built into Node.js and returns one of the six named category strings.
Candidate key construction
Build the lookup key by concatenating the base key, an underscore character,
and the resolved category. For example, a base key of commands.items with
the resolved category few produces the candidate key commands.items_few.
This constructed string is the first key the registry will search for when
looking up the translated plural form.
Primary locale lookup
Search for the candidate key in the flat map for the requested locale by calling LocaleRegistry.get(locale). The registry holds one Map<string, string> per loaded locale and returns the translated string instantly if the key exists. If the key is found at this step, execution skips ahead to interpolation immediately.
Fallback to the _other category
If the primary lookup returned nothing and the resolved category is not
already other, build a second candidate key using _other as the suffix
and search the same locale map again. This fallback is what allows you to
provide only _one and _other for most languages without needing to
define every possible CLDR category, since other is the mandatory
catch-all.
Default locale fallback
If neither the category key nor the _other key was found in the requested
locale, and the requested locale is not already the configured default
locale, repeat steps three and four against the default locale's flat map.
This tier enables partial translations: a language that only covers some
keys will silently produce the default locale's text for missing entries
rather than breaking the response.
Safe raw key return
If all previous lookups failed, return the raw base key string as-is rather than throwing an error. The pluralization engine never throws a missing key exception at request time, which means a missing translation surfaces as visible placeholder text in the UI without crashing the bot or leaving the interaction unanswered. This behavior makes it safe to ship partial translations to production while work on remaining locales is still in progress.
Variable interpolation
Merge the numeric count into the vars object under the key count, making
it available as {{ count }} in the translated string without any extra
work from the caller. Replace every {{ varName }} placeholder in the
resolved string with its corresponding value from the merged vars object.
The fully interpolated string is returned to the handler that called tp()
or tpUser().
Providing translations for a language with complex plural rules
Russian requires four entries to be correct. If you skip _few or _many, the engine falls back
to _other for those counts, which may be grammatically incorrect for your specific string.
{
"items": {
"_one": "{{count}} предмет",
"_few": "{{count}} предмета",
"_many": "{{count}} предметов",
"_other": "{{count}} предмета"
}
}i18n.tp('commands.items', 'ru', 1); // '1 предмет' (one)
i18n.tp('commands.items', 'ru', 2); // '2 предмета' (few)
i18n.tp('commands.items', 'ru', 5); // '5 предметов' (many)
i18n.tp('commands.items', 'ru', 11); // '11 предметов' (many, teen numbers are always many in Russian)
i18n.tp('commands.items', 'ru', 21); // '21 предмет' (one, 21 ends in 1)Using pluralization in handlers
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { InventoryCommand } from '../commands/inventory.command';
@SlashCommandHandler(InventoryCommand)
export class InventoryHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const count = await getInventoryCount(interaction.user.id);
const locale = interaction.locale;
const message = this.i18n.tp('commands.items', locale, count, { count });
await interaction.reply(message);
}
}Custom plural rules for edge cases
When Intl.PluralRules gives wrong results for a specific locale identifier (this is uncommon but
can happen with custom locale strings or locale variants not covered by the CLDR data in the current
Node.js version), you can override the rule at config time:
import { defineI18n } from '@spraxium/i18n';
export const i18nConfig = defineI18n({
defaultLocale: 'en-US',
references: [{ name: 'commands', path: './locales/commands' }],
pluralRules: {
'pt-BR': (count) => (count === 1 ? 'one' : 'other'),
ar: (count) => {
if (count === 0) return 'zero';
if (count === 1) return 'one';
if (count === 2) return 'two';
if (count % 100 >= 3 && count % 100 <= 10) return 'few';
if (count % 100 >= 11 && count % 100 <= 99) return 'many';
return 'other';
},
},
});Custom rules receive the numeric count and must return one of the six category strings. They run
before Intl.PluralRules and override it completely for the specified locale. Locales without a
custom rule continue using Intl.PluralRules as normal.
The variable
The count passed to tp() or tpUser() is automatically available as {{count}} in the
translated string. You do not need to pass it explicitly in the vars object. The engine merges it
in automatically so that every plural variant can reference the count without extra boilerplate.
{
"items": {
"_one": "You have one item remaining",
"_other": "You have {{count}} items remaining"
}
}If you pass additional vars, they are merged with count and all are available as {{varName}}
placeholders:
i18n.tp('commands.items', 'en-US', 5, { maxCapacity: 10 });
// Translation: 'You have {{count}} of {{maxCapacity}} item slots used'
// Result: 'You have 5 of 10 item slots used'