Intl API or the easiest list formatting you'll ever use

Using Intl.ListFormat to standardize the usage of and/or grammar in a programmatic way

Read time is about 11 minutes

Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.

Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.

Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.

Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.

Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.

Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.

If you've ever written manual logic to join a list of items with commas and "and" or "or" in JavaScript, you know how annoying it gets. Edge cases everywhere — two items vs three, Oxford comma debates, and don't even think about internationalization. I originally discovered this API while working on VA.gov, where we needed to dynamically list credential providers like "Login.gov, ID.me, or DS Logon" across dozens of pages in multiple contexts. I found a native browser API that handles all of it in a few lines.

The problem: manual list formatting

We originally had an inelegant solution for dealing with lists. We wanted the ability to dynamically use <strong> tags while also correctly formatting the list with commas and "or":

// ORIGINAL — the hard way export default function ListText({ isBold = false }) { const animals = ['Dog', 'Cat', 'Rhino', 'Penguin']; return animals.map((animal, index) => { const totalAnimals = animals.length; const last = index === totalAnimals - 1; const comma = !last && totalAnimals > 2; const or = totalAnimals >= 2 && last; const renderAnimal = isBold ? <strong>{animal}</strong> : animal; return ( <React.Fragment key={index}> {or && 'or '} {renderAnimal} {comma && ','} {!last && ' '} </React.Fragment> }) }

The above piece of code would render: Dog, Cat, Rhino, or Penguin

Because my team was on a tight deadline this piece of code — however unsightly — was allowed through to production. To be fair, this code works as expected. But it's fragile. Change the list to two items and you need to verify the comma logic still holds. Want "and" instead of "or"? That's another conditional. Need to support Spanish or French? Good luck.

I took it upon myself to see if I could find a more elegant solution that would improve its reusability.

Intl.ListFormat to the rescue

Definitely check out the MDN documentation about Intl.ListFormat, but essentially it allows you to enable language-sensitive list formatting. That's right, this will work with any language.

// THE BETTER WAY export default function ListText({ isBold = false }) { const animals = ["Dog", "Cat", "Rhino", "Penguin"]; return new Intl.ListFormat("en", { style: "long", type: "disjunction" }) .formatToParts(animals) .map(({ type, value }) => { return type === "element" && isBold ? <strong>{value}</strong> : value; }); }

The above piece of code would still render: Dog, Cat, Rhino, or Penguin

Same output, a fraction of the logic. No manual comma tracking, no off-by-one bugs, no edge case conditionals.

Let's break this down

  1. We create a new instance of Intl.ListFormat
  2. We set our list format to use English 'en' and set our config to use a style of 'long' and type of 'disjunction'
  3. We pass our animals array to the formatToParts method which returns a new array with the commas and or inserted (the length becomes 7 — each element plus the literals between them)
  4. We map through the returned array and check if the type is an 'element'. The 'element' will always coincide with the value from our array, whereas the type 'literal' will be the comma or or respectively
  5. We check if our isBold prop is set to true and return the value between the <strong> tags, otherwise we just pass the value

The three formatting types

Intl.ListFormat supports three type options that cover most real-world scenarios:

const items = ["React", "Vue", "Svelte"]; // type: "conjunction" — joins with "and" new Intl.ListFormat("en", { type: "conjunction" }).format(items); // → "React, Vue, and Svelte" // type: "disjunction" — joins with "or" new Intl.ListFormat("en", { type: "disjunction" }).format(items); // → "React, Vue, or Svelte" // type: "unit" — joins without conjunction words new Intl.ListFormat("en", { type: "unit" }).format(items); // → "React, Vue, Svelte"

And each type supports three style options — 'long', 'short', and 'narrow':

const units = ["6 hours", "32 minutes"]; new Intl.ListFormat("en", { type: "unit", style: "long" }).format(units); // → "6 hours, 32 minutes" new Intl.ListFormat("en", { type: "unit", style: "short" }).format(units); // → "6 hr, 32 min" new Intl.ListFormat("en", { type: "unit", style: "narrow" }).format(units); // → "6 hr 32 min"

Internationalization for free

The real power here is that you get internationalization without any extra work. Just change the locale string:

const frameworks = ["React", "Vue", "Svelte"]; // English new Intl.ListFormat("en", { type: "conjunction" }).format(frameworks); // → "React, Vue, and Svelte" // Spanish new Intl.ListFormat("es", { type: "conjunction" }).format(frameworks); // → "React, Vue y Svelte" // Japanese new Intl.ListFormat("ja", { type: "conjunction" }).format(frameworks); // → "React、Vue、Svelte" // Arabic new Intl.ListFormat("ar", { type: "conjunction" }).format(frameworks); // → "React وVue وSvelte"

Each locale automatically applies its own grammar rules — comma placement, conjunction words, and even character separators. No if statements, no translation files for punctuation, no edge cases to maintain.

format vs formatToParts

There are two methods available on Intl.ListFormat, and which one you use depends on whether you need control over the individual pieces:

format() returns a plain string. Use this when you just need the formatted text:

new Intl.ListFormat("en", { type: "conjunction" }).format(["A", "B", "C"]); // → "A, B, and C"

formatToParts() returns an array of objects with type and value. Use this when you need to wrap individual elements in JSX (like our <strong> tag example) or apply different styling:

new Intl.ListFormat("en", { type: "conjunction" }).formatToParts([ "A", "B", "C", ]); // → [ // { type: "element", value: "A" }, // { type: "literal", value: ", " }, // { type: "element", value: "B" }, // { type: "literal", value: ", and " }, // { type: "element", value: "C" } // ]

This is what made the original React use case possible — we needed formatToParts to conditionally wrap each element in a <strong> tag while leaving the literal commas and conjunctions untouched.

Browser support

Intl.ListFormat is supported in all modern browsers. Chrome 72+, Firefox 78+, Safari 14.1+, and Edge 79+ all have full support. If you need to support IE11 (my condolences), you'll need a polyfill from FormatJS. For Node.js, it's been available since v12 with full ICU data.

At this point there's really no reason not to use it in production.

Making it reusable

Our code is now much more extensible. Here's a more complete React component that you could drop into any project:

interface FormattedListProps { items: string[]; type?: "conjunction" | "disjunction" | "unit"; locale?: string; bold?: boolean; } export function FormattedList({ items, type = "conjunction", locale = "en", bold = false, }: FormattedListProps) { return new Intl.ListFormat(locale, { style: "long", type }) .formatToParts(items) .map(({ type: partType, value }, index) => ( <span key={index}> {partType === "element" && bold ? <strong>{value}</strong> : value} </span> )); }

Now you can use it anywhere:

<FormattedList items={["Dogs", "Cats", "Penguins"]} type="disjunction" bold /> // → <strong>Dogs</strong>, <strong>Cats</strong>, or <strong>Penguins</strong> <FormattedList items={["React", "TypeScript"]} /> // → React and TypeScript <FormattedList items={["Hola", "Mundo"]} locale="es" /> // → Hola y Mundo

Other Intl APIs worth knowing

Intl.ListFormat is part of a larger family of internationalization APIs built into the browser. A few others worth exploring:

  • Intl.DateTimeFormat — format dates and times by locale without libraries like Moment.js or date-fns
  • Intl.NumberFormat — format numbers, currencies, and percentages ($1,234.56, €1.234,56)
  • Intl.RelativeTimeFormat — "3 days ago", "in 2 hours" — localized
  • Intl.PluralRules — determine singular/plural forms for any locale

All of these follow the same pattern: create an instance with a locale, pass your data, get properly formatted output. No npm packages required.

Finishing up

Stop writing manual comma logic. Intl.ListFormat handles commas, conjunctions, edge cases, and internationalization natively — with zero dependencies. It's one of those browser APIs that's been available for years but somehow flies under the radar. I hope developers running into a similar issue find this useful.