A UI builds a message as `${n} item${n === 1 ? '' : 's'}`. Explain why that pattern is an internationalization defect, and what Intl.PluralRules provides instead.
answer
- English's two forms are not universal
- up to six categories exist
- select returns a tag, not text
- catalog keyed by locale and category
- ordinal rules give 1st/2nd/3rd
basics
~20 sThat pattern hardcodes English's two-form plural. Other languages have up to six categories with different rules. Intl.PluralRules.select(n) returns the category tag for a locale — 'one', 'few', 'many', 'other' — which you use to pick a message from a per-language catalog.
solid answer
~40 sThe ternary encodes one language's grammar into application code. English needs two forms, but Polish and Russian need four, Arabic six, and Japanese one. `new Intl.PluralRules('pl-PL').select(2)` returns `'few'` while `select(5)` returns `'many'` — so a translator working from an English string with a singular and a plural slot literally cannot express correct Polish. The API returns a *category tag*, never text: you keep a message catalog keyed by locale and category and look the message up with `select(n)`. Passing `{ type: 'ordinal' }` gives ordinal categories instead, which is how you derive "1st / 2nd / 3rd / 4th" without a hardcoded suffix table. Format the number itself with `Intl.NumberFormat` and substitute it into the chosen message — `Intl.PluralRules` does not format or translate anything.
code
javascript · 13 linesconst pr = new Intl.PluralRules('pl-PL');
console.log(pr.select(1), pr.select(2), pr.select(5)); // "one" "few" "many"
const messages = {
one: '1 plik',
few: '{n} pliki',
many: '{n} plików',
other: '{n} pliku',
};
const render = (n) => messages[pr.select(n)].replace('{n}', String(n));
console.log(render(2)); // "2 pliki"
console.log(render(5)); // "5 plików"go deeper
Know that Intl.PluralRules('locale').select(n) returns a category name such as 'one' or 'other', and that you supply the actual words for each category rather than getting text back.
Explain that category sets differ by language — English two, Polish four, Arabic six — and that a two-slot source message makes correct translation impossible. Mention type: 'ordinal' for 1st/2nd/3rd and that formatting the number is a separate step.
Show how this shapes the message catalog: whole sentences keyed by locale and plural category, no fragment concatenation, and Intl.ListFormat / Intl.RelativeTimeFormat for the adjacent grammar problems developers hand-roll.
Own the localization contract end to end — the message format the pipeline uses, who can add a plural form without a code change, and how a new locale ships without touching application logic.
## The defect in the ternary `${n} item${n === 1 ? '' : 's'}` is not a formatting shortcut; it is a grammar rule embedded in code. It says: this language has exactly two forms, and the boundary is at 1. That statement is false in most languages. - **Japanese** has one form for all counts. - **English** has two: one / other. - **Polish** has four for cardinals: `one` (1), `few` (2–4 and similar), `many` (5+ and similar), `other` (fractions). - **Arabic** uses all six categories: `zero`, `one`, `two`, `few`, `many`, `other`. Even the English case is subtler than it looks: 0 takes the plural form, and 1.0 does not behave like 1 in every locale's rules. The practical consequence is worse than an ugly string. If your source message has exactly two slots, the translation pipeline has two slots, and a Polish translator has no place to put the third and fourth forms. The defect is in the *shape* of the message, not in the wording. ## What the API actually returns ```js const pr = new Intl.PluralRules('pl-PL'); pr.select(1); // "one" pr.select(2); // "few" pr.select(5); // "many" ``` A **category tag**, not text. The six possible tags are `zero`, `one`, `two`, `few`, `many` and `other`; which subset a locale uses is locale data. `other` always exists and is the fallback, which is why a catalog must always define it. You own the strings: ```js const messages = { one: '1 plik', few: '{n} pliki', many: '{n} plików', other: '{n} pliku', }; const render = (n) => messages[pr.select(n)].replace('{n}', String(n)); render(2); // "2 pliki" render(5); // "5 plików" ``` That is the whole contract: the runtime knows the *rules*, your catalog knows the *words*. ## Ordinals ```js const ord = new Intl.PluralRules('en', { type: 'ordinal' }); ord.select(1); // "one" -> 1st ord.select(2); // "two" -> 2nd ord.select(3); // "few" -> 3rd ord.select(4); // "other" -> 4th ``` The familiar English suffix table (`st`, `nd`, `rd`, `th`, with the 11–13 exception) is exactly what ordinal plural rules encode. Deriving suffixes from `select` is both shorter and correct for the teens, and works for locales whose ordinal rules are nothing like English's. ## Formatting the number is a separate job `Intl.PluralRules` never formats. In a message like "1,234 files" the number needs `Intl.NumberFormat` for grouping and digits, then substitution into the selected message. Selecting the category from the *raw* number and formatting separately is the correct order; selecting from an already-formatted string cannot work, because `select` takes a number. A related subtlety: the category can depend on how many fraction digits you display. `Intl.PluralRules` accepts the same `minimumFractionDigits` / `maximumFractionDigits` options as `Intl.NumberFormat`, so if you display "1.0 star", construct the rules with matching digit options and the selection follows what the user actually sees. ## The neighbouring builders Two more constructors solve adjacent grammar problems that people also hand-roll: ```js new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }) .format(['a', 'b', 'c']); // "a, b, and c" new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(-1, 'day'); // "yesterday" ``` `Intl.ListFormat` knows the locale's separator and conjunction, including the serial-comma question; joining with `', '` and `' and '` is the English-only version of the same defect. `Intl.RelativeTimeFormat` produces "in 3 days" / "3 days ago", and with `numeric: 'auto'` it substitutes idiomatic words like "yesterday" and "tomorrow" where the locale has them. ## Where this leaves message design The general rule is that user-visible sentences should not be assembled from fragments in code. Every fragment concatenation encodes a word order, a plural rule, or a conjunction that some language disagrees with. Keep whole messages in a per-locale catalog, key them by plural category where a count is involved, and let the `Intl` constructors supply the rules — categories, list conjunctions, relative-time wording — that vary between them.
- What does new Intl.PluralRules('en-US').select(0) return, and why does that matter?`'other'` — English uses the plural form for zero ("0 items"). It matters because developers often special-case zero with a third branch in code, when the plural system already handles it. If you want a distinct "No items" wording that is a *product* decision, so express it as an explicit zero case in the catalog rather than assuming the grammar demands it.
- How do you decide the number's own formatting in a pluralized message?Separately, with `Intl.NumberFormat`, then substitute the formatted number into the message chosen by `select`. Select the category from the raw number, not from a formatted string. If you display a fixed number of decimals, pass the same `minimumFractionDigits` / `maximumFractionDigits` to `Intl.PluralRules` so the category matches what the user sees.
- Which Intl constructor would you use for "apples, oranges, and pears"?`Intl.ListFormat`, with `type: 'conjunction'` for "and" or `'disjunction'` for "or", and `style` for how verbose the joining is. Joining manually with `', '` plus a final `' and '` bakes in English word order and English's serial-comma convention; other locales use different separators and place the conjunction differently.
saying these in an interview costs you the question
- Assuming every language has exactly singular and plural
- Expecting select() to return the pluralized word
- Special-casing zero because the grammar supposedly requires it
- Building sentences by concatenating translated fragments
- Joining lists with ', ' and ' and ' for every locale