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
conjunctionSojourner, Opportunity, Spirit, Curiosity, and Perseverance
disjunctionSojourner, Opportunity, Spirit, Curiosity, or Perseverance
unitSojourner, Opportunity, Spirit, Curiosity, Perseverance
short
conjunctionSojourner, Opportunity, Spirit, Curiosity, & Perseverance
disjunctionSojourner, Opportunity, Spirit, Curiosity, or Perseverance
unitSojourner, Opportunity, Spirit, Curiosity, Perseverance
narrow
conjunctionSojourner, Opportunity, Spirit, Curiosity, Perseverance
disjunctionSojourner, Opportunity, Spirit, Curiosity, or Perseverance
unitSojourner 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.