JavaScript join in TypeScript

September 21, 202610 min read
Requirements:
ArraysGenerics| Unions

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'] becomesSeparatorLiteral type
join(', ')'a, , b'yoursNo
join()'a,,b'always ,No
toString()'a,,b'always ,No
`${list}`'a,,b'always ,No
map(String).join(', ')'a, null, b'yoursNo

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 two

TypeScript 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:

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.

Share this article

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

Practice with Challenges

Put your javascript join in typescript knowledge to the test with these related challenges.

#5310Join
Medium
#847String Join
Hard

Related Concepts

Concepts that build on or relate to javascript join in typescript.

JavaScript concat in TypeScriptTemplate Literal TypesTypeScript TuplesJavaScript some in TypeScript