anyplural API reference

Overview

anyplural turns a count into its correct plural form in any locale. It asks native Intl.PluralRules which CLDR category the number falls into, formats the number with Intl.NumberFormat, and stitches the two together. No rule tables, no dictionaries, zero dependencies.

n === 1 ? 'item' : 'items' is an English-only assumption. Russian needs three forms, Arabic six, Japanese one. The browser has known all of this for years.

import { anyplural } from 'anyplural'

anyplural(1, { one: 'item', other: 'items' })
// "1 item"

anyplural(5, { one: 'item', other: 'items' })
// "5 items"

anyplural(5, { one: 'год', few: 'года', many: 'лет' }, { locale: 'ru' })
// "5 лет"

anyplural(3, { one: 'st', two: 'nd', few: 'rd', other: 'th' }, { type: 'ordinal' })
// "3rd"

Install

npm install anyplural
# or
pnpm add anyplural
# or
yarn add anyplural

Zero dependencies, ESM and CJS builds with types. Or take the whole family at once with npm install anyfamily.

anyplural()

The single entry point. Pass a count and the word forms, optionally pass options. Returns the formatted count followed by the matching form.

anyplural(count, forms)
anyplural(count, forms, options?)

anyplural(1, { one: 'item', other: 'items' }, { locale: 'en' })
// "1 item"

anyplural(1500, { one: 'item', other: 'items' }, { locale: 'en' })
// "1,500 items"   — the count goes through Intl.NumberFormat

anyplural(2, { one: 'год', few: 'года', many: 'лет' }, { locale: 'ru' })
// "2 года"

anyplural(5, { one: 'plik', few: 'pliki', many: 'plików' }, { locale: 'pl' })
// "5 plików"

Throws RangeError if count is not a finite number, and also if the resolved category has no form and no other to fall back to.

anypluralParts()

Same arguments as anyplural(), but returns { type, value } parts instead of a string — style the number apart from the word, or rebuild the output your own way.

import { anypluralParts } from 'anyplural'

anypluralParts(5, { one: 'item', other: 'items' }, { locale: 'en' })
// [
//   { type: 'integer', value: '5' },
//   { type: 'literal', value: ' items' },
// ]

// React: bold the number
anypluralParts(count, forms).map((p, i) =>
  p.type === 'integer' ? <b key={i}>{p.value}</b> : p.value,
)

Number parts come straight from Intl.NumberFormat.formatToParts, so a grouped count splits into integer / group / integer the same way it would there. The word is a single literal.

Forms

forms is a plain object keyed by CLDR plural category. Supply other as the catch-all — it is the terminal fallback for every locale.

// English cardinal — two forms is enough
{ one: 'item', other: 'items' }

// Russian cardinal — never resolves to 'other' for integers
{ one: 'год', few: 'года', many: 'лет' }

// English ordinal — the suffix, not the word
{ one: 'st', two: 'nd', few: 'rd', other: 'th' }

// Japanese — one form covers everything
{ other: '個' }

A locale that can never reach a category may omit it — Russian cardinal integers never resolve to other, so the example above is complete. But a form that resolves to a missing category with no reachable fallback throws at runtime, so when in doubt include other.

Categories

The six CLDR categories. Which ones a locale actually uses — and for which numbers — is Intl.PluralRules' business, not yours.

zeroArabic 0; a few locales onlyar: 0 → "zero"
onesingular — not always literally 1ru: 1, 21, 31 → "one"
twodualar: 2 → "two"
fewsmall pluralru: 2–4, 22–24 → "few"
manylarge pluralru: 5–20, 25–30 → "many"
otherthe catch-all every locale can reachen: 0, 2, 99 → "other"

The names are labels, not quantities. Russian resolves 21 and 31 to one, and 0 to many. Write forms per category, never per number.

The zero form

zero gets one extra behaviour on top of the CLDR category: when the count is exactly 0 and a zero form is present, that form is the whole output — the number is dropped. It is the natural way to write an empty state.

anyplural(0, { zero: 'No messages', one: 'message', other: 'messages' }, { locale: 'en' })
// "No messages"        — not "0 No messages"

anyplural(0, { one: 'message', other: 'messages' }, { locale: 'en' })
// "0 messages"         — no zero form, so the count stays

anyplural(2, { zero: 'No messages', one: 'message', other: 'messages' }, { locale: 'en' })
// "2 messages"         — zero only applies at 0

anypluralParts reports this as a single literal part, since there is no number to split out.

This is separate from Arabic, where zero is a real CLDR category that Intl.PluralRules resolves on its own.

Options

localestring | string[]default: runtime locale

Any valid BCP 47 locale tag, or a fallback array — 'en', 'ru', 'pt-BR', ['sr-Latn-RS', 'en']. Decides both the plural rules and the number formatting.

type'cardinal' | 'ordinal'default: 'cardinal'

Cardinal counts how many ('5 items'); ordinal ranks ('3rd'). Mapped straight to Intl.PluralRules.

formatIntl.NumberFormatOptionsdefault: plain integer

Any options accepted by Intl.NumberFormat, applied to the count — grouping, decimals, compact notation, currency.

Locales

Same call, different rules — no locale files, no rule tables of your own.

const en = { one: 'item', other: 'items' }
anyplural(1, en, { locale: 'en' })   // "1 item"
anyplural(0, en, { locale: 'en' })   // "0 items"

const ru = { one: 'год', few: 'года', many: 'лет' }
anyplural(1, ru, { locale: 'ru' })   // "1 год"
anyplural(2, ru, { locale: 'ru' })   // "2 года"
anyplural(5, ru, { locale: 'ru' })   // "5 лет"
anyplural(21, ru, { locale: 'ru' })  // "21 год"   ← 'one', not 'many'

const pl = { one: 'plik', few: 'pliki', many: 'plików' }
anyplural(2, pl, { locale: 'pl' })   // "2 pliki"
anyplural(5, pl, { locale: 'pl' })   // "5 plików"

anyplural(1, { other: '個' }, { locale: 'ja' })   // "1 個"

The count is formatted in the same locale, so grouping separators and digits follow it too — 1,500 in English, 1 500 in Russian, Eastern Arabic numerals in ar-EG.

anyplural(1500, { other: 'items' }, { locale: 'en', format: { notation: 'compact' } })
// "1.5K items"

anyplural(1999, { one: 'euro', other: 'euros' }, {
  locale: 'en',
  format: { style: 'currency', currency: 'EUR' },
})
// "€1,999.00 euros"

React

anyfamily-react exposes the same function as useAnyplural, reading the locale from a shared AnyfamilyProvider so you do not thread it through every call.

import { AnyfamilyProvider, useAnyplural } from 'anyfamily-react'

function Inbox({ count }: { count: number }) {
  return <p>{useAnyplural(count, { one: 'message', other: 'messages' })}</p>
}

<AnyfamilyProvider locale="ru">
  <Inbox count={5} />
</AnyfamilyProvider>

SSR

anyplural is pure and synchronous — no clock, no state — so server and client render identically. Pass an explicit locale to keep it that way regardless of the runtime default.

import { anyplural } from 'anyplural'

export function ResultCount({ n }: { n: number }) {
  return <p>{anyplural(n, { one: 'result', other: 'results' }, { locale: 'en' })}</p>
}

Compatibility

anyplural uses Intl.PluralRules and Intl.NumberFormat — both available everywhere modern. Ordinal rules need Intl.PluralRules with type: 'ordinal', supported since the same releases.

Node.js18+CI runs the suite on Node 20, 22, 24
Chrome63+
Firefox58+
Safari13+
Edge18+
Vercel Edge Runtime
Cloudflare Workers
Deno

Limitations

A few things worth knowing before you ship:

You supply the words

anyplural picks the category and formats the number; it ships no dictionaries, so the forms are yours to write. That is what keeps it correct in every locale and near-zero in size — but it is not a translation system. For full message catalogs with interpolation, reach for an i18n framework.

Categories are not numbers

'one' does not mean 1. Russian resolves 21 and 31 to 'one', and 0 to 'many'. Write forms per category, never per number — that is the whole point of Intl.PluralRules.

A missing reachable category throws

If the resolved category has no form and there is no 'other' to fall back to, anyplural raises a RangeError rather than guessing or rendering an empty word. Supply 'other' unless you are certain the locale can never reach it.

Integers are the well-trodden path

Intl.PluralRules also has rules for decimals, and passing a fractional count works, but the category a locale picks for 1.5 surprises people. Check the output for the locales you ship before relying on it.