JavaScript concat in TypeScript
Array.prototype.concat joins arrays together and hands you a new one. The JavaScript concat method
is old, boring, and completely predictable at runtime — which is exactly why the TypeScript side
catches people off guard. The signature is stricter than most people expect, and it behaves
differently from the spread operator in a way that has nothing to do with syntax.
Two examples cover the runtime half. The rest of this page is about the types: where the element
type comes from, why mixing types is an error here but fine with spread, and what happens to a
tuple that goes through concat.
What JavaScript concat does
It returns a new array containing the receiver's elements followed by everything you passed in. It never touches the original.
const first = [1, 2]
const second = [3, 4]
const joined = first.concat(second) // [1, 2, 3, 4] — number[]
console.log(first) // [1, 2] — untouchedArguments can be arrays or loose values, mixed freely, as many as you like:
const base = [1, 2]
const withItems = base.concat(5, 6) // [1, 2, 5, 6]
const mixedForm = base.concat([5], 6) // [1, 2, 5, 6]
const manyPieces = base.concat([3], [4], [5]) // [1, 2, 3, 4, 5]Every one of those is number[]. No mutation, no surprises. Now the types.
The signature has two overloads
Here is the declaration from lib.es5.d.ts:
interface ConcatExample<T> {
concat(...items: ConcatArray<T>[]): T[]
concat(...items: (T | ConcatArray<T>)[]): T[]
}The first overload covers "arrays only". The second covers the mixed form, where an argument can be
a bare T or an array of them. Both return T[], and that is the detail that drives everything
below: the result element type is always T, the element type of the array you called it on.
concat does not infer a new type parameter from its arguments the way
flatMap does with its callback.
ConcatArray<T> is a small structural interface rather than T[]:
interface ConcatArrayShape<T> {
readonly length: number
readonly [n: number]: T
join(separator?: string): string
slice(start?: number, end?: number): T[]
}It only asks for indexed read access, length, join, and slice. That is why a readonly
array is a perfectly good argument — it satisfies all four members without being a mutable T[].
const target = [1, 2]
const frozen: readonly number[] = [7, 8]
const merged = target.concat(frozen) // number[] ✅concat locks the element type
This is the one that sends people to a search engine. Concatenating a number[] with a string[]
does not produce a union. It produces an error.
const counts: number[] = [1, 2]
const labels: string[] = ['a']
// const broken = counts.concat(labels)
// ❌ TS2769: No overload matches this call.
// Argument of type 'string[]' is not assignable to parameter of type 'ConcatArray<number>'.
// The types returned by 'slice(...)' are incompatible between these types.Read the signature again and it follows: T is fixed at number by the receiver, so every argument
has to be a number or a ConcatArray<number>. A string[] is neither. The error surfaces through
slice because that is where the two ConcatArray shapes structurally disagree.
The spread operator has no such constraint. It builds a fresh array literal, and TypeScript infers the element type from everything in it:
const nums: number[] = [1, 2]
const strs: string[] = ['a']
const viaSpread = [...nums, ...strs] // (string | number)[] ✅If you want the union and you want concat, say so on the receiver:
const mixed: (string | number)[] = [1, 2]
const moreLabels: string[] = ['a']
const fine = mixed.concat(moreLabels, 3) // (string | number)[] ✅An array whose elements are a union type accepts both. It is the
number[] receiver that was doing the rejecting, not concat itself.
Tuples come out as plain arrays
concat returns T[]. A tuple's T is the union of its element types, so positions and length
are gone the moment you call it.
const pair: [string, number] = ['a', 1]
const extended = pair.concat(['b', 2]) // (string | number)[] — not [string, number, string, number]The compiler is being honest: the signature promises T[] and nothing more. If you need the
positional structure preserved, spread into a tuple-typed literal instead:
const start: [string, number] = ['a', 1]
const keptShape = [...start, 'b'] as [string, number, string] // ✅ positions intactThe tuple page covers why literal arrays widen and how as const
pins them down.
Which leads to a sharp edge worth knowing about. as const makes the element types narrower, and
narrower T means stricter arguments:
const pinned = [1, 2] as const // readonly [1, 2] — T is 1 | 2
// const grown = pinned.concat([3])
// ❌ TS2769: Type '3' is not assignable to type '2 | 1'.3 is not a 1 and not a 2, so neither overload matches. The usual fix is to stop using concat
here — [...pinned, 3] works and infers (1 | 2 | 3)[] without an argument to reject.
Only one level gets flattened
concat unpacks the arrays you pass it. It does not recurse into them.
const nested = [[1], [2]]
const stillNested = nested.concat([[3]]) // [[1], [2], [3]] — number[][]T here is number[], so the result is number[][] and the inner arrays survive. The argument
[[3]] is one array containing one array; the outer layer is unpacked, the inner one is not. That
is the same one-level rule flatMap follows, and when you need to go deeper
the answer is the same: chain .flat().
Readonly in, mutable out
A small practical win. ReadonlyArray has its own concat, and it also returns T[] — a mutable
array. That makes concat a one-call escape hatch when you need a writable copy of something
frozen.
const config: readonly string[] = ['--strict']
const withExtra = config.concat('--noEmit') // string[] — mutable ✅
withExtra.push('--watch') // fineSpread gets you there too ([...config]), so pick whichever reads better. The point is that neither
one leaks the readonly into the result.
Worth saying out loud, because the types will not warn you: the new array is a shallow copy. The objects inside it are the same objects, so mutating one is visible through both arrays.
const originals = [{ id: 1 }]
const copied = originals.concat([{ id: 2 }])
copied[0].id = 99
console.log(originals[0].id) // 99 — same object, not a cloneconcat copies the list, never its contents. Spread behaves identically here, and so does
slice(). If you need the elements themselves isolated, you have to clone them yourself.
JavaScript concat vs spread vs push
Three ways to join arrays, three different jobs.
const a = [1, 2]
const b = [3, 4]
const viaConcat = a.concat(b) // new array, a untouched
const viaSpreadOp = [...a, ...b] // new array, a untouched
a.push(...b) // mutates a in place, returns the new lengthUse this to choose:
pushis the only mutating one. It returns a number, not an array, so it does not chain. It is also the fastest option for appending to an array you already own — butpush(...bigArray)spreads into arguments, and very large arrays can blow the call stack. Loop instead at that size.- Spread is the better default for merging a known handful of arrays. It accepts any iterable, not just arrays, and it infers unions across mixed element types instead of rejecting them.
concatis the one that composes. It is a method, so it chains, and the strictTis a feature when you want the compiler to stop astringfrom sneaking into yournumber[].
const one = [1]
const two = [2]
const three = [3]
const chained = one.concat(two).concat(three) // number[]One trap on that last point. Building an array by calling concat in a loop or a reduce copies
everything on every iteration, which is quadratic:
const groups = [[1], [2], [3]]
const slow = groups.reduce((acc, group) => acc.concat(group), [] as number[]) // ❌ copies each pass
const fast = groups.flat() // ✅ one passflat() is the right tool for "flatten this array of arrays". Keep concat for joining a fixed,
small number of arrays.
concat takes arrays, not iterables
Look at ConcatArray<T> one more time and the limit is obvious: it wants length, join, and
slice. A Set has none of those, so it is not a valid argument even though it is perfectly
iterable.
const tally = [1, 2]
const unique = new Set([3, 4])
// const rejected = tally.concat(unique)
// ❌ TS2769: Argument of type 'Set<number>' is not assignable to parameter of
// type 'ConcatArray<number>'. Type 'Set<number>' is missing the following
// properties: length, join, sliceSpread does not care, because it consumes anything with Symbol.iterator:
const scores = [1, 2]
const extra = new Set([3, 4])
const fromSpread = [...scores, ...extra] // number[] ✅
const fromConcat = scores.concat(Array.from(extra)) // number[] ✅ — convert firstArray-likes fail for the same reason. An object with numeric keys and a length satisfies half the
interface and TypeScript names the missing half directly:
const parts = ['x']
const arrayLike = { 0: 'a', 1: 'b', length: 2 } as const
// const alsoRejected = parts.concat(arrayLike)
// ❌ TS2769: ... is missing the following properties from type
// 'ConcatArray<string>': join, sliceAt runtime concat would have appended that object whole rather than unpacking it, so the compile
error is saving you from a bug, not inventing one. Array.from converts both cases.
The empty-array seed problem
An empty array literal with no annotation infers as never[], and never is a very strict T:
// const seeded = [].concat([1, 2])
// ❌ TS2769: Type 'number' is not assignable to type 'never'.This bites most often as a reduce seed, which is why the example earlier wrote [] as number[].
Annotate the empty array and everything lines up:
const chunks = [[1], [2]]
const collected = chunks.reduce<number[]>((acc, chunk) => acc.concat(chunk), [])
// number[] ✅Giving reduce an explicit type argument is usually the tidiest fix — it types the seed and the
accumulator in one place, instead of relying on an assertion at the end of the line.
concat on strings
String.prototype.concat exists as well, typed as you would guess:
[object Object]It takes ...strings: string[] and returns string. There is no reason to reach for it —
template literals and + are clearer — but it turns up in older code
and now you know it is not the array method.
Working with a generic T
Inside a generic function, concat behaves exactly as the signature says, which makes it a
comfortable building block:
function combine<T>(head: T[], tail: readonly T[]): T[] {
return head.concat(tail)
}
const ids = combine([1, 2], [3]) // number[]
const names = combine(['a'], ['b']) // string[]tail is typed readonly T[] on purpose — concat only reads it, so there is no reason to demand
a mutable array from the caller. If the relationship between T and the call site is still fuzzy,
generics is the longer explanation.
Summary
concat returns a new T[] and leaves the original alone. Three things to remember:
- The result element type is the receiver's
T, always. Mixing anumber[]with astring[]is a compile error, not a union — widen the receiver or use spread. - Tuples and
as constarrays lose their structure or reject the arguments outright, becauseTcollapses to a union of the element types. - One level of flattening only, and calling it in a loop copies the whole array each time. Reach for
flat()when that is what you actually meant.
Once the runtime version is comfortable, the type-level version is a short step: the
Concat challenge asks you to implement the same operation on tuple types,
where the positions you just watched concat throw away are the entire point.
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