Rendering a list of items as human-readable text is a deceptively common need, and .join(', ') falls short as soon as the result is meant to read like language:
console.log(['apple'].join(', ')) // apple
// looks good
console.log(['apple', 'grape'].join(', ')) // apple, grape
// nah, I want "apple and grape"
console.log(['apple', 'grape', 'pear'].join(', ')) // apple, grape, pear
// wut?
String concatenation is one route. A better one is Intl.ListFormat, a standard API that handles this formatting across locales:
const items = [
'Sojourner',
'Opportunity',
'Spirit',
'Curiosity',
'Perseverance',
]
const formatter = new Intl.ListFormat('en', {
style: 'long',
type: 'conjunction',
})
console.log(formatter.format(items))
// logs: "Sojourner, Opportunity, Spirit, Curiosity, and Perseverance"
How Intl.ListFormat is configured
The constructor's first argument is the locale — 'en' in the example above. The second is an options object with two settings: style, which accepts 'long', 'short' or 'narrow', and type, which accepts 'conjunction', 'disjunction' or 'unit'. For English, the combinations produce:
long | |
|---|---|
conjunction | Sojourner, Opportunity, Spirit, Curiosity, and Perseverance |
disjunction | Sojourner, Opportunity, Spirit, Curiosity, or Perseverance |
unit | Sojourner, Opportunity, Spirit, Curiosity, Perseverance |
short | |
|---|---|
conjunction | Sojourner, Opportunity, Spirit, Curiosity, & Perseverance |
disjunction | Sojourner, Opportunity, Spirit, Curiosity, or Perseverance |
unit | Sojourner, Opportunity, Spirit, Curiosity, Perseverance |
narrow | |
|---|---|
conjunction | Sojourner, Opportunity, Spirit, Curiosity, Perseverance |
disjunction | Sojourner, Opportunity, Spirit, Curiosity, or Perseverance |
unit | Sojourner Opportunity Spirit Curiosity Perseverance |
Switching locales does not always behave the way you would predict:
new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format(items)
// Sojourner Opportunity Spirit Curiosity Perseverance
new Intl.ListFormat('es', { style: 'narrow', type: 'unit' }).format(items)
// Sojourner Opportunity Spirit Curiosity Perseverance
new Intl.ListFormat('de', { style: 'narrow', type: 'unit' }).format(items)
// Sojourner, Opportunity, Spirit, Curiosity und Perseverance
For de, the combination of narrow and unit reads more like long with conjunction. There is also a localeMatcher option taking 'lookup' or 'best fit' and defaulting to 'best fit'; it governs how the browser negotiates a locale from the one supplied to the constructor. Testing could not surface a functional difference between the two values.
When to roll your own
Because the capability comes from the Intl standard, a large number of locales is supported and internationalization arrives essentially for free — which is a good reason to trust the platform here rather than hand-rolling the logic. Internationalization in particular is rarely as simple as it first appears. Still, there are cases the platform does not cover, and that is when a custom function is warranted. The original implementation used reduce rather than a for loop, and it worked in the codebase like this:
function listify(
array,
{ conjunction = 'and ', stringify = (item) => item.toString() } = {},
) {
return array.reduce((list, item, index) => {
if (index === 0) return stringify(item)
if (index === array.length - 1) {
if (index === 1) return `${list} ${conjunction}${stringify(item)}`
else return `${list}, ${conjunction}${stringify(item)}`
}
return `${list}, ${stringify(item)}`
}, '')
}
const to = `To: ${listify(mentionedMembersNicknames)}`
// and
const didYouMean = `Did you mean ${listify(closeMatches, {
conjunction: 'or ',
})}?`
On revisiting the problem, the use case turned out not to be as special as it seemed. Rewriting it on top of Intl.ListFormat, with a small change to the API, produced a simpler implementation:
function listify(
array,
{
type = 'conjunction',
style = 'long',
stringify = (item) => item.toString(),
} = {},
) {
const stringified = array.map((item) => stringify(item))
const formatter = new Intl.ListFormat('en', { style, type })
return formatter.format(stringified)
}
// no change for the default case
const to = `To: ${listify(mentionedMembersNicknames)}`
// switch to using the "type" option rather than overloading/abusing the term "conjunction"
const didYouMean = `Did you mean ${listify(closeMatches, {
type: 'disjunction',
})}?`
A TypeScript port of the finished code adds autocomplete for the option values and type checking on the stringify method:
// unfortunately TypeScript doesn't have Intl.ListFormat yet 😢
// so we'll just add it ourselves:
type ListFormatOptions = {
type?: 'conjunction' | 'disjunction' | 'unit'
style?: 'long' | 'short' | 'narrow'
localeMatcher?: 'lookup' | 'best fit'
}
declare namespace Intl {
class ListFormat {
constructor(locale: string, options: ListFormatOptions)
public format: (items: Array<string>) => string
}
}
type ListifyOptions<ItemType> = {
type?: ListFormatOptions['type']
style?: ListFormatOptions['style']
stringify?: (item: ItemType) => string
}
function listify<ItemType>(
array: Array<ItemType>,
{
type = 'conjunction',
style = 'long',
stringify = (thing: { toString(): string }) => thing.toString(),
}: ListifyOptions<ItemType> = {},
) {
const stringified = array.map((item) => stringify(item))
const formatter = new Intl.ListFormat('en', { style, type })
return formatter.format(stringified)
}
Browser support
Always verify support for the APIs you rely on. caniuse.com covers this query, and the MDN page for Intl.ListFormat includes its own compatibility chart.



