JavaScript join in TypeScript
Array.prototype.join collapses an array into a single string. The JavaScript join method takes a
separator, walks the elements, and hands one string back. That part has been in the language since
ES1 and nothing about it has changed since.
The TypeScript side is the half worth reading. join is one of the few array methods that throws
away everything the compiler knows about your data: hand it a tuple of string literals and you still
get a plain string back. This page covers what it returns, the two runtime behaviours people get
wrong, and how to recover a literal type when you actually need one.
What JavaScript join does
Walk the elements, stringify each one, glue them together with the separator.
const cities = ['Berlin', 'Paris', 'Lisbon']
const commaSeparated = cities.join() // 'Berlin,Paris,Lisbon'
const arrowSeparated = cities.join(' → ') // 'Berlin → Paris → Lisbon'
const squashed = cities.join('') // 'BerlinParisLisbon'The default separator is a comma, not an empty string. That first line surprises people who expect
join() and join('') to mean the same thing. The original array is never touched.
An explicit undefined counts as "no argument", so you get the comma back:
const letters = ['a', 'b']
const explicitUndefined = letters.join(undefined) // 'a,b'The signature TypeScript uses
Here is the whole declaration from the standard library:
interface JoinSignature {
join(separator?: string): string
}There is nothing else to it — no overloads, no generics, no type predicate. Two things follow.
The return type is string. Always. Not string | undefined for an empty array, not a literal
type for a tuple of literals. Every call to join anywhere in your codebase has the same return
type.
The separator has to be a string, and TypeScript is stricter here than the runtime is:
declare const parts: string[]
// parts.join(0)
// ❌ TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
const zeroJoined = parts.join(String(0)) // ✅ 'a0b0c'JavaScript would happily coerce that 0 to '0'. The type definition does not let you, which is
the right call — a numeric separator is almost always a mistake.
The JavaScript join method turns null into an empty string
This is the behaviour that produces bug reports.
const withGaps = ['a', null, 'b', undefined, 'c']
const joinedGaps = withGaps.join('-') // 'a--b--c'
const mappedGaps = withGaps.map(String).join('-') // 'a-null-b-undefined-c'join stringifies null and undefined as ''. Every other route to a string disagrees:
String(null) gives 'null', ${null} in a template literal gives 'null', and JSON.stringify
gives 'null'. join is the odd one out, and holes in a sparse array behave the same way.
TypeScript will not warn you about it. (string | null | undefined)[] has a join method like any
other array, and the result is a string either way. If the empty slots in the output are wrong,
filter before you join:
const contactLines: (string | null | undefined)[] = ['Ada Lovelace', null, 'London']
const address = contactLines.filter((line): line is string => line != null).join(', ')
// 'Ada Lovelace, London'Since TypeScript 5.5 you can drop the explicit line is string and write
.filter((line) => line != null) — the compiler infers the predicate from the body and narrows the
array to string[] for you. Writing it out still works and is clearer when the check is more
involved than one comparison.
join returns string even for a tuple
Give join everything it could possibly need to compute a literal type, and it still widens:
const routeSegments = ['api', 'v2', 'users'] as const
const routePath = routeSegments.join('/')
// routePath: string — not 'api/v2/users'The compiler knows each element is a string literal and knows the separator is '/'. It does not
matter. The declaration says : string, so string is what you get.
That becomes a real problem the moment the result feeds something typed more narrowly:
type Route = 'api/v2/users' | 'api/v2/orders'
// const typedRoute: Route = routeSegments.join('/')
// ❌ TS2322: Type 'string' is not assignable to type 'Route'.For a fixed number of elements, a template literal is the easy way out, because the compiler does evaluate those in a const context:
const literalRoute = `${routeSegments[0]}/${routeSegments[1]}/${routeSegments[2]}` as const
// 'api/v2/users'That only scales as far as you are willing to write index accesses. For an arbitrary tuple, you need the type-level version.
Building a literal type from a join
The type system can do what the method will not, with a recursive conditional type that peels one element off at a time:
type JoinTuple<T extends readonly string[], Sep extends string> = T extends readonly []
? ''
: T extends readonly [infer Head extends string]
? Head
: T extends readonly [infer Head extends string, ...infer Tail extends string[]]
? `${Head}${Sep}${JoinTuple<Tail, Sep>}`
: string
type RoutePath = JoinTuple<['api', 'v2', 'users'], '/'>
// 'api/v2/users'The single-element case exists so the last element does not pick up a trailing separator, and the
final : string branch catches a plain string[] whose length is unknown — which is the honest
answer there.
Pairing it with the runtime call gives you a helper whose return type matches what actually comes back:
function joinTyped<const T extends readonly string[], Sep extends string>(
items: T,
separator: Sep,
): JoinTuple<T, Sep> {
return items.join(separator) as JoinTuple<T, Sep>
}
const apiPath = joinTyped(['api', 'v2', 'users'], '/')
// apiPath: 'api/v2/users'The const modifier on T is what stops the argument widening to string[], so you can pass a
plain array literal without an as const at the call site. The cast in the body is unavoidable:
join is declared to return string, and no amount of generic machinery changes that. You are
asserting that your type-level implementation matches the runtime one.
If you want to build that conditional type yourself rather than copy it, the Join challenge walks through exactly this, and String Join is the harder variant once the first one clicks.
Nested arrays and objects go through toString
join does not flatten. Each element is stringified on its own, and for an array that means its own
toString() — which is join(',') with the default separator.
const matrix = [
[1, 2],
[3, 4],
]
const flatText = matrix.join(';') // '1,2;3,4'The outer separator applies between the top-level elements only. If you want one separator all the
way down, flatten first — flatMap or flat() both work.
Objects get the same treatment, with the result you would expect:
const records = [{ id: 1 }, { id: 2 }]
const recordText = records.join(', ') // '[object Object], [object Object]'TypeScript allows this without a word of complaint, because join is declared on every array no
matter what the element type is. There is no constraint saying the elements have to be
string-shaped. Map them yourself:
[object Object]join vs toString vs template literals
All three produce a string from an array, and only one of them lets you choose the separator.
| Approach | ['a', null, 'b'] becomes | Separator | Literal type |
|---|---|---|---|
join(', ') | 'a, , b' | yours | No |
join() | 'a,,b' | always , | No |
toString() | 'a,,b' | always , | No |
`${list}` | 'a,,b' | always , | No |
map(String).join(', ') | 'a, null, b' | yours | No |
Interpolating an array into a template literal calls toString(), which calls join(), which is
why the middle three rows agree. None of them recover a literal type — that is the
JoinTuple job from the section above.
For text a human reads, none of these are right. Intl.ListFormat handles the "and" and the locale
rules that a separator cannot:
const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
const prose = formatter.format(['Ada', 'Alan', 'Grace'])
// 'Ada, Alan, and Grace'Its format takes an Iterable<string>, so unlike join it rejects a (string | null)[] at
compile time. That strictness is a feature.
Readonly arrays work unchanged
join is declared on ReadonlyArray as well as Array, so as const data and readonly
parameters need no cast:
function formatTags(tagList: readonly string[]) {
return tagList.join(' · ')
}
const tagLine = formatTags(['ts', 'js'] as const) // ✅ 'ts · js'Taking readonly string[] in your own signatures is the habit worth keeping. It accepts mutable and
readonly arrays both, and join never mutates anything anyway.
Empty arrays, single elements, and CSV
join never returns undefined and never throws. An empty array gives you an empty string, and a
one-element array gives you that element with no separator anywhere:
const emptyList: string[] = []
const singleList = ['only']
const emptyJoined = emptyList.join(', ') // ''
const singleJoined = singleList.join(', ') // 'only'The empty case is worth a thought whenever the joined string ends up in a template. Tags: ${[].join(', ')}
renders as Tags: with nothing after it, and no type error tells you that is happening. Guard on
length when an empty list should read differently:
declare const selectedTags: string[]
const tagSummary = selectedTags.length > 0 ? selectedTags.join(', ') : 'none'There is also the old trick of joining a sparse array to repeat a string —
new Array(4).join('ab') gives 'ababab', three separators between four holes. It works, it is
off by one from what most people expect, and 'ab'.repeat(3) says the same thing without the
puzzle. Prefer repeat.
Finally, join(',') is not a CSV encoder. It does not quote values, so any element containing a
comma, a quote, or a newline silently corrupts the row:
const csvValues = ['Lovelace, Ada', 'London']
const brokenRow = csvValues.join(',') // 'Lovelace, Ada,London' — three fields, not twoTypeScript cannot catch this, because a value with a comma in it and a value without one are both
just string. Use a real CSV library, or quote and escape each field yourself before joining.
Summary
The JavaScript join method is simple at runtime and lossy in the type system. Four things to carry away:
- The default separator is
,, and an explicitundefinedstill gives you,. Pass''if you want no separator. nullandundefinedelements become empty strings, unlike every other way of stringifying them. Filter first when that matters.- The return type is always
string, even for aas consttuple. A template literal recovers the literal type for a fixed length; a recursive conditional type does it for any length. - Elements are stringified individually via
toString(), so nested arrays use their own comma and objects become[object Object]. Map to strings before you join.
For the array method that goes the other way, JavaScript concat covers building arrays up rather than collapsing them, and JavaScript some is the equivalent page for existence checks.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges