Skip to content

Plural rules

Read this page if you translate into any language that needs more than two plural forms — Arabic, Russian, Polish, Czech, Welsh, Slovenian, Croatian, Lithuanian, Irish, Hebrew, Tachelhit, Langi and many others.

How many a language needs is not a matter of taste. CLDR settles it, and the range is wide:

languageCLDR categories
Chinese, Japanese, Korean, Yoruba, Igbo1other
English, German, Swahili, Hausa, Zulu2one, other
Spanish, French, Portuguese3one, many, other
Langi3zero, one, other
Nama3one, two, other
Russian, Polish, Czech, Ukrainian4one, few, many, other
Slovenian4one, two, few, other
Arabic, Welsh, Cornish6zero, one, two, few, many, other

Slovenian carries the worked examples below: four categories including a dual, driven by n mod 100, so every way a rule can go wrong appears at counts small enough to tabulate. Arabic and Welsh are the deep end at six. Langi and Nama have a zero and a dual respectively, which matters further down.

A message written for English carries two forms. Rendered in Arabic by a rule that knows only "singular or plural", four of those six categories are unreachable; in Polish the same message never leaves few, and in Slovenian it sticks on the dual.

The short version

If your Vue application already passes pluralRules to createI18n, transcribe the same rule here and the two ends will agree. That is the whole story, and it is the recommended path.

If it does not, both ends use vue-i18n's default rule, which cannot reach a fourth or fifth form in any language — and this port reproduces that faithfully rather than improving on it.

How a form is chosen

A plural rule is a function:

(choice, choices_length) -> index

choice is the count, choices_length is how many forms the message supplies, and the return value is which form to use. Exactly the same signature as in JavaScript, which is what makes a rule transcribe line for line.

The default rule

With no rule of your own, vue-i18n uses this — and so does this port:

python
def plural_default(choice, choices_length):
    choice = abs(choice)
    if choices_length == 2:
        return 0 if choice == 1 else 1
    return min(choice, 2)

It ignores the locale entirely. Two forms mean singular/plural; three or more clamp at index 2. A message with four or five forms can never reach the fourth or the fifth:

json
{ "trips": "ni povezav | {count} povezava | {count} povezavi | {count} povezave | {count} povezav" }
countdefault rule renderscorrect Slovenian
0ni povezavni povezav
11 povezava1 povezava
22 povezavi2 povezavi
33 povezavi3 povezave
55 povezavi5 povezav
105105 povezavi105 povezav

min(choice, 2) stops at the third form and stays there, so any message with more than three forms loses the fourth onwards — in every language, at the same place. Arabic has the furthest to fall, with three of its six categories past the cut.

The result is wrong Slovenian, and it is what the browser renders too. Matching it is the point: a back end that quietly corrected this would disagree with the page in front of the user.

Passing your own rule

python
def south_slavic(choice, choices_length, _org=None):
    if choices_length == 2:
        return 0 if choice == 1 else 1
    if choices_length == 3:
        return min(choice, 2)
    if choice == 0:
        return 0
    if choice % 100 == 1:
        return 1
    if choice % 100 == 2:
        return 2
    if choice % 100 in (3, 4):
        return 3
    return 4

ctx = create_core_context(
    locale="sl",
    fallback_locale="en",
    messages=messages,
    plural_rules={"sl": south_slavic},
)
countwith the rule above
0ni povezav
11 povezava
22 povezavi
33 povezave
55 povezav
101101 povezava
102102 povezavi
103103 povezave

The third parameter is the built-in rule, which upstream passes so a custom rule can delegate to it. A two-argument rule works too.

Make your rule total

A rule that returns an index past the last form crashes in the browser — vue-i18n reads undefined and raises UNEXPECTED_RETURN_TYPE. The rule above returns 4, so it requires messages with five forms; give a Slovene message only four and the page breaks.

The cheap insurance is to clamp on the way out:

python
    return min(index, choices_length - 1)

which degrades to the last available form instead of failing.

Do not hard-code the number into a form

Writing the one form as "ena povezava" reads better at 1 and is wrong at 101, which is also one in Slovenian — the message would say "ena povezava" about a hundred and one things. The same trap catches "dve povezavi" at 102. A category is not a count: use {count} in every inflected form and let the rule pick the ending.

Unless you want both

A rule receives the count, not a category, so it can give exactly one a form of its own — "ena povezava" at 1 and "101 povezava" at 101:

python
# ni povezav | ena povezava | {count} povezava | {count} povezavi | {count} povezave | {count} povezav
#      0            1               2                  3                  4                 5

def south_slavic_exact_one(choice, choices_length, _org=None):
    if choices_length < 6:
        return min(choice, choices_length - 1)   # degrade instead of crashing
    if choice == 0:
        return 0
    if choice == 1:
        return 1                                 # the word, only for a real single
    if choice % 100 == 1:
        return 2                                 # 101, 201 - same category, different copy
    if choice % 100 == 2:
        return 3
    if choice % 100 in (3, 4):
        return 4
    return 5
countrenders
0ni povezav
1ena povezava
22 povezavi
101101 povezava
102102 povezavi
105105 povezav

This is the same move as the explicit zero the CLDR module adds: a form CLDR has no category for, because the copy wants it even though the grammar does not. Rules are not limited to CLDR's categories — they are limited to the number of forms your message supplies.

The cost is the same as for any custom rule: both ends must agree, so the Vue application needs the same function.

The CLDR module

If you would rather not hand-write the rule, vue_i18n.plurals carries one derived from CLDR:

python
from vue_i18n.plurals import rule_for

ctx = create_core_context(..., plural_rules={"sl": rule_for("sl")})

It covers 224 locales from CLDR 48, adds an explicit zero form that CLDR does not have, and copes with a message carrying fewer forms than the locale has categories by dropping categories in a defined order.

It will not match a hand-written rule

This is a different rule, not a better spelling of yours. At three forms it gives zero, one, other where a typical hand-written south-Slavic rule gives the same, but at four it gives zero, one, two, other where pure CLDR would give one, two, few, other.

If your front end has a rule, port that one. Using this module on one end and a hand-written rule on the other reintroduces exactly the disagreement this library exists to remove.

It is opt-in. Nothing in the runtime reaches for it unless you pass it.

What vue-i18n 12 changes

Upstream has already addressed this. vue-i18n 12 — in alpha at the time of writing, not yet released — resolves through Intl.PluralRules, in this order:

your custom rule  >  Intl.PluralRules  >  the default rule above

So a message with as many forms as the locale has CLDR categories selects correctly: a four-form Arabic or Slovenian message picks the right one of the four. When a message carries more forms than the locale has categories, it falls back to the clamp, unchanged.

This port tracks the released line, so it behaves like 11.x. When 12 is released the pin moves and the port follows.

A custom rule stays worth having even then, for a reason that has nothing to do with version numbers: CLDR gives almost no language a zero category. Ten locales in CLDR 48 have one — Arabic, Welsh, Cornish, Latvian, Chuvash, Colognian, Prussian, and the two that make the point best because nobody expects them, Langi in Tanzania and Anii in Benin and Togo.

Everything else — English, Spanish, Slovenian, Swahili, Russian, Chinese — sends a count of zero to other, because grammatically that is where it belongs. So a message that says "no trips" instead of "0 trips" cannot be reached through Intl.PluralRules in any of them, in vue-i18n 12 or any version after it. An explicit zero is a decision about your copy rather than about grammar, and it needs a rule that knows you made it. That is what the first precedence slot is for, and it is why the recommendation at the top of this page does not change.

Released under the MIT License. vue-i18n is © kazuya kawaguchi and contributors; this is an unofficial port and is not affiliated with intlify.