JavaScript Basics: Join Human-Readable Lists with Intl.ListFormat
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 1The tested runtime prints:
v24.19.01. 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.mjsOutput:
Included: tea, coffee, and water
Choose one: tea, coffee, or waterThe 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.mjsOutput:
2 pens and 1 notebook
No items
1 notebookA 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.mjsOutput:
[Ada], [Bo], and [Chen]
element, literal, element, literal, elementelement 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_dirAcknowledgements
This post was created with assistance from AI.