loke.dev
Header image for A Precise Plural for the Global User

A Precise Plural for the Global User

How to handle ordinals and language-specific plural forms without building a massive manual lookup table or a fragile regex.

· 4 min read

I used to think I was a localization genius because I wrote a ternary operator that added an "s" to the end of a word if the count wasn't one. count === 1 ? 'item' : 'items'. Simple, right? Then I had to support a project in Russian, where the word for "file" changes depending on whether you have 1, 2, or 5 of them. My elegant ternary turned into a nested nightmare of if-else blocks that still managed to be wrong half the time.

The hard truth is that pluralization isn't a binary state. It’s a linguistic minefield. If you're still building manual lookup tables or wrestling with regex to handle "1st, 2nd, and 3rd," you’re doing too much manual labor. JavaScript has a built-in tool that handles the heavy lifting of global grammar: Intl.PluralRules.

The "One, Two, Many" Problem

Most English speakers assume plurals are easy: it’s either one or it’s not. But languages like Arabic have six different plural forms (zero, one, two, few, many, and other). Even if you only care about English, you still have to deal with ordinals.

Instead of trying to code those rules yourself, you can ask the browser (or Node.js) what "category" a number falls into for a specific language.

const pr = new Intl.PluralRules('en-US');

console.log(pr.select(0)); // "other" (0 items)
console.log(pr.select(1)); // "one"   (1 item)
console.log(pr.select(2)); // "other" (2 items)

Wait, why does select() return a string like "one" or "other" instead of the actual word? Because Intl.PluralRules doesn't translate for you—it categorizes. It tells you which bucket a number belongs to so you can pick the right string from your own translation object.

Building a Scalable Pluralizer

Here is how you actually use this in a project. You create a small mapping object and let the Intl API act as the key-picker.

function formatComments(count, locale = 'en-US') {
  const rules = new Intl.PluralRules(locale);
  const key = rules.select(count);

  const texts = {
    en: {
      one: `${count} comment`,
      other: `${count} comments`,
    },
    es: {
      one: `${count} comentario`,
      other: `${count} comentarios`,
    }
  };

  // Fallback logic is important here
  const lang = locale.split('-')[0];
  return texts[lang][key] || texts[lang]['other'];
}

console.log(formatComments(1, 'en-US')); // "1 comment"
console.log(formatComments(5, 'en-US')); // "5 comments"

This keeps your logic clean. If you need to add a language with more complex rules, you just add more keys to your texts object (like few or many) without touching the underlying logic.

The Ordinal Headache (1st, 2nd, 3rd)

Nothing makes a UI look more amateur than "You are in 1th place." Handling ordinals with a regex or a switch(count % 10) is a recipe for edge-case bugs (looking at you, 11th, 12th, and 13th).

By passing { type: 'ordinal' } into the constructor, the engine switches from counting quantities to ranking positions.

const getOrdinal = (n, locale = 'en-US') => {
  const pr = new Intl.PluralRules(locale, { type: 'ordinal' });
  const key = pr.select(n);

  const suffixes = {
    one: 'st',
    two: 'nd',
    few: 'rd',
    other: 'th',
  };

  return `${n}${suffixes[key]}`;
};

console.log(getOrdinal(1));  // "1st"
console.log(getOrdinal(2));  // "2nd"
console.log(getOrdinal(3));  // "3rd"
console.log(getOrdinal(11)); // "11th"
console.log(getOrdinal(21)); // "21st"

The magic here is that Intl.PluralRules knows that in English, 11 is "other" (eleventh), but 21 is "one" (twenty-first). I don't have to remember that rule; I just have to provide the suffix for the category.

Why not just use a library?

You could pull in i18next or FormatJS, and for massive enterprise apps, you probably should. But those libraries are heavy. If you're building a smaller site or a component library, you shouldn't force your users to download a 15KB dependency just to handle the word "items."

The Intl object is already there. It’s sitting in the browser's memory, maintained by experts who actually understand the nuances of the Czech dative case.

A Quick Gotcha: Performance

Creating a new Intl.PluralRules instance is relatively expensive. If you’re formatting a giant table with thousands of rows, don't create the instance inside your loop. Memoize it or keep it in a cache.

const prCache = new Map();

function getRules(locale, options = {}) {
  const cacheKey = `${locale}-${JSON.stringify(options)}`;
  if (!prCache.has(cacheKey)) {
    prCache.set(cacheKey, new Intl.PluralRules(locale, options));
  }
  return prCache.get(cacheKey);
}

Summary

Pluralization is a solved problem, but we keep trying to resolve it with messy logic. By using Intl.PluralRules, you get:

1. Accuracy: No more "1 items" or "21th place."
2. Clean Code: Your logic stays the same regardless of the language.
3. Zero Weight: You aren't shipping a massive dictionary of rules to the client.

Stop writing regex for your grammar. Let the platform do its job so you can go back to building features that actually matter.