How to choose plural form for ranges like 1-3 items

Use JavaScript to select the correct plural form when displaying ranges of numbers

Introduction

Ranges communicate that a value falls between two endpoints. User interfaces display ranges in contexts like search results showing "Found 10-15 matches", inventory systems showing "1-3 items available", or filters showing "Select 2-5 options". These ranges combine two numbers with descriptive text that must agree grammatically with the range.

When you display a single count, you choose between singular and plural forms: "1 item" versus "2 items". Languages have rules that determine which form applies based on the count. These rules vary by language. English uses singular for one and plural for all other counts. Polish uses different forms for 1, 2-4, and 5 or more. Arabic has six distinct forms based on the count.

Ranges introduce a different challenge. The plural form depends on both the start and end values, not just a single number. In English, "1-2 items" uses plural even though the range starts at 1. Different languages have different rules for determining which plural form applies to a range. The selectRange() method on Intl.PluralRules handles these language-specific rules automatically.

Why ranges need different pluralization rules

Using the select() method on a single number from a range does not work correctly for all languages. You might think to use the end value of the range, but this produces incorrect results in many languages.

Consider English with the range 0-1. Using select() on the end value returns "one", suggesting you should display "0-1 item". This is grammatically incorrect. The correct form is "0-1 items" with the plural.

const rules = new Intl.PluralRules("en-US");

console.log(rules.select(1));
// Output: "one"

// But "0-1 item" is incorrect
// Correct: "0-1 items"

Different languages have explicit rules for ranges that do not match their rules for single counts. In Slovenian, the range 102-201 uses the "few" form, while individual numbers in that range use different forms.

const slRules = new Intl.PluralRules("sl");

console.log(slRules.select(102));
// Output: "few"

console.log(slRules.select(201));
// Output: "few"

console.log(slRules.selectRange(102, 201));
// Output: "few"

Some languages use the start value to determine the form, others use the end value, and others use both values together. The selectRange() method encapsulates these language-specific rules so you do not need to implement them manually.

Create a PluralRules instance for ranges

Create an Intl.PluralRules instance the same way you do for single counts. The instance provides both select() for single numbers and selectRange() for ranges.

const rules = new Intl.PluralRules("en-US");

You can specify options when creating the instance. These options apply to both single counts and ranges.

const rules = new Intl.PluralRules("en-US", {
  type: "cardinal"
});

The type option defaults to "cardinal", which handles counting objects. You can also use "ordinal" for positional numbers, though ordinal ranges are less common in user interfaces.

Reuse the same instance across multiple calls. Creating a new instance for every pluralization is wasteful. Store the instance in a variable or cache it by locale.

Use selectRange to determine plural category for ranges

The selectRange() method takes two numbers representing the start and end of a range. It returns a string indicating which plural category applies: "zero", "one", "two", "few", "many", or "other".

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(0, 1));
// Output: "other"

console.log(rules.selectRange(1, 2));
// Output: "other"

console.log(rules.selectRange(5, 10));
// Output: "other"

In English, ranges almost always use the "other" category, which corresponds to the plural form. This matches how English speakers naturally express ranges with plural nouns.

Languages with more plural forms return different categories based on their specific rules.

const arRules = new Intl.PluralRules("ar-EG");

console.log(arRules.selectRange(0, 0));
// Output: "zero"

console.log(arRules.selectRange(1, 1));
// Output: "one"

console.log(arRules.selectRange(2, 2));
// Output: "two"

console.log(arRules.selectRange(3, 10));
// Output: "few"

The return value is always one of the six standard plural category names. Your code maps these categories to the appropriate localized text.

Map range categories to localized strings

Store the text forms for each plural category in a data structure. Use the category returned by selectRange() to look up the appropriate text.

const rules = new Intl.PluralRules("en-US");
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(1, 3));
// Output: "1-3 items"

console.log(formatRange(0, 1));
// Output: "0-1 items"

console.log(formatRange(5, 10));
// Output: "5-10 items"

This pattern separates the pluralization logic from the localized text. The Intl.PluralRules instance handles the language rules. The Map holds the translations. The function combines them.

For languages with more plural categories, add entries for each category the language uses.

const arRules = new Intl.PluralRules("ar-EG");
const arForms = new Map([
  ["zero", "عناصر"],
  ["one", "عنصر"],
  ["two", "عنصران"],
  ["few", "عناصر"],
  ["many", "عنصرًا"],
  ["other", "عنصر"]
]);

function formatRange(start, end) {
  const category = arRules.selectRange(start, end);
  const form = arForms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(0, 0));
// Output: "0-0 عناصر"

console.log(formatRange(1, 1));
// Output: "1-1 عنصر"

Always provide text for every category the language uses. Check the Unicode CLDR plural rules or test with the API across different ranges to identify which categories are needed.

How different locales handle range pluralization

Each language has its own rules for determining the plural form of ranges. These rules reflect how native speakers naturally express ranges in that language.

const enRules = new Intl.PluralRules("en-US");
console.log(enRules.selectRange(1, 3));
// Output: "other"

const slRules = new Intl.PluralRules("sl");
console.log(slRules.selectRange(102, 201));
// Output: "few"

const ptRules = new Intl.PluralRules("pt");
console.log(ptRules.selectRange(102, 102));
// Output: "other"

const ruRules = new Intl.PluralRules("ru");
console.log(ruRules.selectRange(1, 2));
// Output: "few"

English consistently uses "other" for ranges, making ranges always plural. Slovenian applies more complex rules based on the specific numbers in the range. Portuguese uses "other" for most ranges. Russian uses "few" for certain ranges.

These differences show why hardcoding plural logic fails for international applications. The API encapsulates the knowledge of how each language handles ranges.

Combine with Intl.NumberFormat for complete formatting

Real applications need to format both the numbers and the text. Use Intl.NumberFormat to format the range endpoints according to locale conventions, then use selectRange() to choose the correct plural form.

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1, 3));
// Output: "1-3 items"

console.log(formatRange(1000, 5000));
// Output: "1,000-5,000 items"

The number formatter adds thousands separators. The plural rules select the correct form. The function combines both to produce properly formatted output.

Different locales use different number formatting conventions.

const locale = "de-DE";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "Artikel"],
  ["other", "Artikel"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1000, 5000));
// Output: "1.000-5.000 Artikel"

German uses periods as thousands separators instead of commas. The number formatter handles this automatically. The plural rules determine which form of "Artikel" to use.

Compare selectRange with select for single values

The select() method handles single counts, while selectRange() handles ranges. Use select() when displaying a single quantity and selectRange() when displaying a range between two values.

const rules = new Intl.PluralRules("en-US");

// Single count
console.log(rules.select(1));
// Output: "one"

console.log(rules.select(2));
// Output: "other"

// Range
console.log(rules.selectRange(1, 2));
// Output: "other"

console.log(rules.selectRange(0, 1));
// Output: "other"

For single counts, the rules depend only on that one number. For ranges, the rules consider both endpoints. In English, a range starting at 1 still uses the plural form, even though the single count 1 uses the singular form.

Some languages show more dramatic differences between single count rules and range rules.

const slRules = new Intl.PluralRules("sl");

// Single counts in Slovenian
console.log(slRules.select(1));
// Output: "one"

console.log(slRules.select(2));
// Output: "two"

console.log(slRules.select(5));
// Output: "few"

// Range in Slovenian
console.log(slRules.selectRange(102, 201));
// Output: "few"

Slovenian uses "one", "two", and "few" for different single counts based on complex rules. For ranges, it applies different logic that considers both numbers together.

Handle ranges where start and end are equal

When the start and end values are the same, you are displaying a range with no width. Some applications use this to represent an exact value in a context where ranges are expected.

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(5, 5));
// Output: "other"

console.log(rules.selectRange(1, 1));
// Output: "one"

When both values equal 1, English returns "one", suggesting you should use the singular form. When both values are any other number, English returns "other", suggesting the plural form.

This behavior makes sense if you display the range as "1-1 item" or simply "1 item". For values other than 1, you display "5-5 items" or "5 items".

In practice, you might want to detect when start equals end and display a single value instead of a range.

const rules = new Intl.PluralRules("en-US");
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  if (start === end) {
    const category = rules.select(start);
    const form = forms.get(category);
    return `${start} ${form}`;
  }

  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(1, 1));
// Output: "1 item"

console.log(formatRange(5, 5));
// Output: "5 items"

console.log(formatRange(1, 3));
// Output: "1-3 items"

This approach uses select() for equal values and selectRange() for actual ranges. The output reads more naturally because it avoids displaying "1-1" or "5-5".

Handle edge cases with selectRange

The selectRange() method validates its inputs. If either parameter is undefined, null, or cannot be converted to a valid number, the method throws an error.

const rules = new Intl.PluralRules("en-US");

try {
  console.log(rules.selectRange(1, undefined));
} catch (error) {
  console.log(error.name);
  // Output: "TypeError"
}

try {
  console.log(rules.selectRange(NaN, 5));
} catch (error) {
  console.log(error.name);
  // Output: "RangeError"
}

Validate your inputs before passing them to selectRange(). This is particularly important when working with user input or data from external sources.

function formatRange(start, end) {
  if (typeof start !== "number" || typeof end !== "number") {
    throw new Error("Start and end must be numbers");
  }

  if (isNaN(start) || isNaN(end)) {
    throw new Error("Start and end must be valid numbers");
  }

  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

The method accepts numbers, BigInt values, or strings that can be parsed as numbers.

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(1, 5));
// Output: "other"

console.log(rules.selectRange(1n, 5n));
// Output: "other"

console.log(rules.selectRange("1", "5"));
// Output: "other"

String inputs are parsed as numbers. This allows flexibility in how you call the method, but you should prefer passing actual number types when possible for clarity.

Handle decimal ranges

The selectRange() method works with decimal numbers. This is useful when displaying ranges of fractional quantities like measurements or statistics.

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(1.5, 2.5));
// Output: "other"

console.log(rules.selectRange(0.5, 1.0));
// Output: "other"

console.log(rules.selectRange(1.0, 1.5));
// Output: "other"

English treats all these decimal ranges as plural. Other languages may have different rules for decimal ranges.

When formatting decimal ranges, combine selectRange() with Intl.NumberFormat configured for the appropriate decimal precision.

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale, {
  minimumFractionDigits: 1,
  maximumFractionDigits: 1
});
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "kilometer"],
  ["other", "kilometers"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1.5, 2.5));
// Output: "1.5-2.5 kilometers"

console.log(formatRange(0.5, 1.0));
// Output: "0.5-1.0 kilometers"

The number formatter ensures consistent decimal display. The plural rules determine the correct form based on the decimal values.

Browser support and compatibility

The selectRange() method is relatively new compared to the rest of the Intl API. It became available in 2023 as part of the Intl.NumberFormat v3 specification.

Browser support includes Chrome 106 and later, Firefox 116 and later, Safari 15.4 and later, and Edge 106 and later. The method is not available in Internet Explorer or older browser versions.

For applications targeting modern browsers, you can use selectRange() without a polyfill. If you need to support older browsers, check for the method's existence before using it.

const rules = new Intl.PluralRules("en-US");

if (typeof rules.selectRange === "function") {
  // Use selectRange for range pluralization
  console.log(rules.selectRange(1, 3));
} else {
  // Fall back to select with the end value
  console.log(rules.select(3));
}

This fallback approach uses select() on the end value when selectRange() is unavailable. This is not linguistically perfect for all languages, but it provides a reasonable approximation for older browsers.

Polyfills are available through packages like @formatjs/intl-pluralrules if you need comprehensive support for older environments.

When to use selectRange versus select

Use selectRange() when your UI explicitly displays a range with both start and end values visible to the user. This includes contexts like search results showing "Found 10-15 matches", inventory showing "1-3 items in stock", or filters showing "Select 2-5 options".

Use select() when displaying a single count, even if that count represents an approximate or summarized value. For example, "About 10 results" uses select(10) because you are displaying a single number, not a range.

If your range is displayed using Intl.NumberFormat.formatRange() for the numbers, use selectRange() for the accompanying text. This ensures consistency between the number formatting and text pluralization.

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "result"],
  ["other", "results"]
]);

function formatSearchResults(start, end) {
  const rangeFormatted = numberFormat.formatRange(start, end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `Found ${rangeFormatted} ${form}`;
}

console.log(formatSearchResults(10, 15));
// Output: "Found 10–15 results"

This pattern uses formatRange() from Intl.NumberFormat to format the numbers and selectRange() from Intl.PluralRules to choose the text. Both methods operate on ranges, ensuring correct handling for all languages.