Skip to content
JavaScript Basics: Join Human-Readable Lists with Intl.ListFormat

JavaScript Basics: Join Human-Readable Lists with Intl.ListFormat

October 20, 2026

Joining labels with a comma does not produce every kind of readable list. Intl.ListFormat adds locale-aware separators and connectors, with separate modes for lists joined by words such as “and” or “or.”

These examples were tested on Linux with Node.js v24.19.0. They use built-in APIs only, require no network access, and include assertions that check the results. See MDN’s Intl.ListFormat reference. These examples use en-US explicitly; punctuation and wording for other locales depend on the runtime’s locale data.

Prepare a scratch directory

You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. Save each named source file as UTF-8; .mjs selects ES modules. Each example runs independently.

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

1. Choose between all items and alternatives

Save this as format-list-choices.mjs:

import assert from 'node:assert/strict';

const items = ['tea', 'coffee', 'water'];
const all = new Intl.ListFormat('en-US', {
  style: 'long', type: 'conjunction',
});
const choice = new Intl.ListFormat('en-US', {
  style: 'long', type: 'disjunction',
});
assert.equal(all.format(items), 'tea, coffee, and water');
assert.equal(choice.format(items), 'tea, coffee, or water');
assert.deepEqual(items, ['tea', 'coffee', 'water']);
console.log(`Included: ${all.format(items)}`);
console.log(`Choose one: ${choice.format(items)}`);

Run it:

node format-list-choices.mjs

Output:

Included: tea, coffee, and water
Choose one: tea, coffee, or water

The same labels can express inclusion or alternatives. type chooses that distinction; style: 'long' asks for full connectors. The formatter keeps input order and leaves the array unchanged. It does not translate tea, coffee, or water; supply labels already prepared for the reader’s language.

2. Prepare strings and handle an empty list

Save this as format-order-labels.mjs:

import assert from 'node:assert/strict';

const formatter = new Intl.ListFormat('en-US', { type: 'conjunction' });
const order = [{ count: 2, label: 'pens' }, { count: 1, label: 'notebook' }];
const labels = order.map(item => `${item.count} ${item.label}`);
function summary(items) {
  return items.length === 0 ? 'No items' : formatter.format(items);
}
assert.equal(summary(labels), '2 pens and 1 notebook');
assert.equal(summary([]), 'No items');
assert.equal(summary(['1 notebook']), '1 notebook');
assert.equal(formatter.format([]), '');
assert.throws(() => formatter.format([1, 2]), TypeError);
console.log(summary(labels));
console.log(summary([]));
console.log(summary(['1 notebook']));

Run it:

node format-order-labels.mjs

Output:

2 pens and 1 notebook
No items
1 notebook

A formatter consumes strings; it does not implicitly convert numeric list elements. Build complete labels first. These fixed order records already contain the correct noun forms; dynamic quantities would need plural handling too. An empty list formats as an empty string, so the wrapper gives this interface an explicit empty-state message. A one-item list needs no connector.

3. Separate item text from punctuation

Save this as inspect-formatted-list-parts.mjs:

import assert from 'node:assert/strict';

const formatter = new Intl.ListFormat('en-US', {
  style: 'long', type: 'conjunction',
});
const names = ['Ada', 'Bo', 'Chen'];
const parts = formatter.formatToParts(names);
assert.equal(parts.map(part => part.value).join(''), formatter.format(names));
assert.deepEqual(parts.filter(part => part.type === 'element')
  .map(part => part.value), names);
const marked = parts.map(part =>
  part.type === 'element' ? `[${part.value}]` : part.value).join('');
assert.equal(marked, '[Ada], [Bo], and [Chen]');
assert.deepEqual(formatter.formatToParts([]), []);
assert.equal(formatter.format(['Ada', 'Ada']), 'Ada and Ada');
console.log(marked);
console.log(parts.map(part => part.type).join(', '));

Run it:

node inspect-formatted-list-parts.mjs

Output:

[Ada], [Bo], and [Chen]
element, literal, element, literal, element

element parts hold the supplied labels, while literal parts hold separators and connectors. This lets a renderer style labels without guessing where commas belong. Preserve literal parts exactly and insert untrusted labels as text in a real interface; this API does not perform HTML escaping. Duplicate labels remain separate entries. The ECMAScript list-format definition describes the parts API.

Clean up

In the same terminal, remove only the named example files, return to your original directory, and remove the empty scratch directory:

rm -- format-list-choices.mjs format-order-labels.mjs inspect-formatted-list-parts.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on