Skip to content

Pluralization & ICU Message Format

Comvi supports the ICU MessageFormat syntax needed for pluralization, ordinal plurals, select branches, variable interpolation, nesting, and quoted literals. ICU is the international standard used by Android, iOS, and most professional localization tools — so your translators already know the syntax.

This page covers the ICU subset Comvi evaluates at runtime. Number, date, and currency formatting are handled by Comvi’s formatting APIs rather than ICU skeletons inside translation strings.

ICU’s plural type adapts a message based on a numeric value:

locales/en/common.json
{
"cart.items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"
}
const { t } = useI18n();
t('cart.items', { count: 0 }); // "Your cart is empty"
t('cart.items', { count: 1 }); // "1 item in your cart"
t('cart.items', { count: 42 }); // "42 items in your cart"

The # symbol is replaced with the numeric value of count.

ICU defines six plural categories. Which categories a language uses depends on its plural rules:

CategoryDescriptionUsed by
zeroZero itemsArabic, Latvian, Welsh
oneSingularEnglish, German, French, most languages
twoDualArabic, Hebrew, Slovenian
fewPaucal / small numbersUkrainian (2-4), Polish (2-4), Czech
manyLarge numbersUkrainian (5-20), Polish (5-21), Arabic
otherGeneral plural (required)All languages

The other category is always required. It serves as the fallback when no other category matches.

Use =N to match exact numbers, overriding the plural category:

{
"notifications": "{count, plural, =0 {No new notifications} =1 {One new notification} other {# new notifications}}"
}

Exact matches are checked before plural categories, so =1 takes priority over one.

When you call t('cart.items', { count: 5 }):

  1. Comvi looks up the key in the current locale and namespace
  2. It passes count to Intl.PluralRules to determine the plural category (one, other, etc.)
  3. The matching branch is selected (exact =N match first, then category, then other)
  4. # is replaced with the numeric value
  5. The final string is returned

Comvi uses JavaScript’s built-in Intl.PluralRules API, which provides correct plural rules for every language defined by the Unicode CLDR.

The select type chooses a message branch based on a string value. This is commonly used for gender but works for any categorical choice:

locales/en/common.json
{
"user.greeting": "{gender, select, male {He left a comment} female {She left a comment} other {They left a comment}}"
}
t('user.greeting', { gender: 'male' }); // "He left a comment"
t('user.greeting', { gender: 'female' }); // "She left a comment"
t('user.greeting', { gender: 'other' }); // "They left a comment"
t('user.greeting', { gender: 'unknown' }); // "They left a comment" (falls back to other)

select works for any enumerated value — formality level, order status, user role, and more.

Formal vs. informal addressing — important for languages like German, French, or Ukrainian where “you” has formal and informal forms:

locales/en/common.json
{
"welcome": "{formality, select, formal {Welcome. How may we assist you?} informal {Hey! What can we help you with?} other {Welcome!}}"
}
locales/de/common.json
{
"welcome": "{formality, select, formal {Willkommen. Wie können wir Ihnen helfen?} informal {Hey! Wie können wir dir helfen?} other {Willkommen!}}"
}
t('welcome', { formality: 'formal' });
// EN: "Welcome. How may we assist you?"
// DE: "Willkommen. Wie können wir Ihnen helfen?"

Order status:

{
"order.status": "{status, select, pending {Your order is being processed} shipped {Your order is on its way} delivered {Your order has been delivered} other {Order status: {status}}}"
}
t('order.status', { status: 'shipped' }); // "Your order is on its way"

For numbers that need ordinal forms (1st, 2nd, 3rd), use selectordinal:

{
"place": "You finished in {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place"
}
t('place', { position: 1 }); // "You finished in 1st place"
t('place', { position: 2 }); // "You finished in 2nd place"
t('place', { position: 21 }); // "You finished in 21st place"

Ordinal categories (one, two, few, other) are language-specific, just like plural categories. The # symbol is replaced with the numeric value.

You can combine plural and select for complex messages:

locales/en/common.json
{
"activity": "{gender, select, male {{count, plural, one {He wrote # comment} other {He wrote # comments}}} female {{count, plural, one {She wrote # comment} other {She wrote # comments}}} other {{count, plural, one {They wrote # comment} other {They wrote # comments}}}}"
}
t('activity', { gender: 'female', count: 3 }); // "She wrote 3 comments"
t('activity', { gender: 'male', count: 1 }); // "He wrote 1 comment"
src/components/CartSummary.vue
<script setup lang="ts">
import { useI18n } from '@comvi/vue';
import { ref } from 'vue';
const { t } = useI18n();
const itemCount = ref(3);
</script>
<template>
<div>
<p>{{ t('cart.items', { count: itemCount }) }}</p>
<p>{{ t('cart.total', { amount: 49.99 }) }}</p>
<button @click="itemCount++">
{{ t('cart.add') }}
</button>
</div>
</template>

If your translation needs to include literal { or } characters, wrap them in single quotes:

Use the syntax '{'key'}' to reference a variable.
t('help.syntax'); // "Use the syntax {key} to reference a variable."

To include a literal single quote, double it:

It''s a beautiful day.
t('weather'); // "It's a beautiful day."
InputOutput
'{' and '}'{ and } (literal braces)
''' (literal single quote)
'any text'any text (literal, no parsing inside)

When an apostrophe sits between two word characters, Comvi treats it as a literal character — no escaping required:

t('cant'); // "I can't do that." (source: "I can't do that.")
t('summer'); // "C'est l'été" (source: "C'est l'été")

You only need to double the apostrophe ('') at positions where a single ' would otherwise start a quoted literal — typically next to {, <, or at word boundaries (e.g. 'morning, o' clock).

For example, o' clock needs a doubled apostrophe because the apostrophe is followed by a space:

t('clock.unescaped'); // "o clock" (source: "o' clock")
t('clock.escaped'); // "o' clock" (source: "o'' clock")

Ready-to-use patterns for frequent translation scenarios:

ScenarioICU Message
Simple greetingHello, {name}!
Item count{count, plural, =0 {No items} one {# item} other {# items}}
Relative count{count, plural, =0 {No new messages} one {# new message} other {# new messages}}
Gender-aware{gender, select, male {His profile} female {Her profile} other {Their profile}}
Optional plural text{count, plural, =0 {Get started} other {Continue ({count})}}
Enumerated status{status, select, pending {Processing} shipped {On its way} delivered {Delivered} other {Unknown}}

Do not hardcode English plural logic. Never write count === 1 ? t('item') : t('items'). Use ICU pluralization and let the message format handle the logic for every language.

// Wrong — breaks for languages with more than two plural forms
const label = count === 1 ? t('item.one') : t('item.other');
// Correct — ICU handles all plural rules
const label = t('items', { count });

Always include the other category. It is the required fallback. If you omit it, values that do not match a more specific category resolve to an empty branch.

Use =0 for “no items” messages. The zero plural category is not the same as the number 0 in most languages. English does not have a zero category, so {count, plural, zero {...} ...} will never match in English. Use =0 instead:

{
"items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}

Keep ICU messages in one line. Some JSON parsers and editors break when ICU syntax spans multiple lines. Keep the entire message on a single line in your JSON file.