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.

Migrating from 1.x

2.0 removed the separate anypluralParts— they are the same functions and values, reached through the one name the package exports.

- import { anyplural, anypluralParts } from 'anyplural'
+ import { anyplural } from 'anyplural'

- anypluralParts(5, forms)
+ anyplural.parts(5, forms)

Arguments, return values and throwing behaviour are unchanged, and nothing else in the API moved. Every any* package follows this shape from 2.0 on: the bare call does the job, everything else hangs off the same name.

anyplural.parts()

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 { anyplural } from 'anyplural'

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

// React: bold the number
anyplural.parts(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

anyplural.parts 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.

What breaks without this

Every one of these is a ternary somebody shipped, and each is wrong somewhere.

count === 1 ? 'item' : 'items'

That is a rule about English, written as if it were a rule about counting. Russian needs three forms, Arabic six, Welsh six. The ternary does not fail loudly — it just writes bad grammar to everyone who is not reading English.

Zero is not always the plural form

English says "0 items", French treats 0 like 1, and several locales have a distinct zero category. Special-casing zero in the code is a fourth wrong answer.

Ordinals follow different rules entirely

1st, 2nd, 3rd, 4th is a separate rule set from one/other, and the two disagree even in English — the plural of 2 is "other", the ordinal of 2 is "two". A suffix table stops working at 11th, 12th, 13th.

A fraction is not one

"1.5 items", not "1.5 item": 1.5 selects other in English even though it is between one and two. Comparing against 1 with === gets that wrong, and comparing with ranges gets it wrong somewhere else.

Recipes

Copy, paste, move on.

// Item counter
anyplural(cart.length, { one: 'item', other: 'items' })
// "3 items"

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

// Ranking / leaderboard position
anyplural(rank, { one: 'st', two: 'nd', few: 'rd', other: 'th' }, { type: 'ordinal' })
// "1st"

// Empty state — the zero form replaces the whole output, number included
anyplural(count, { zero: 'No messages', one: 'message', other: 'messages' })
// 0 → "No messages", 1 → "1 message", 9 → "9 messages"

// Big numbers, grouped
anyplural(inbox, { one: 'email', other: 'emails' }, { locale: 'en' })
// "12,480 emails" 

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>

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"

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>
}

vs the alternatives

What you would otherwise reach for, and what changes if you do.

anyplurali18nextintl-messageformat
gzip< 1kb~14kb~30kb
locale data bundlednoyesyes
plural rulesnative Intltablesnative Intl
dependencies01+4+

anyplural is not an i18n framework — it does one thing. Reach for i18next or ICU MessageFormat when you need message catalogs, interpolation grammars or gender selects.

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.