The JavaScript Spread Operator in TypeScript
The spread operator is three dots. You put them in front of something iterable or in front of an object, and it unpacks the contents into the place you wrote it.
The syntax takes about a minute to learn. What takes longer is the part nobody writes about: what TypeScript infers from a spread, and why the compiler sometimes refuses a call that looks obviously correct. That second half is where this article spends most of its time.
The three places spread shows up
Arrays, objects, and function calls. Same three dots, three slightly different jobs.
// In an array literal — unpack the elements
const scores = [90, 72, 88]
const withBonus = [...scores, 100]
// In an object literal — unpack the properties
const user = { id: 1, name: 'Ada' }
const renamed = { ...user, name: 'Grace' }
// In a function call — unpack the arguments
const values = [3, 1, 2]
const largest = Math.max(...values)All three are plain JavaScript. TypeScript adds no syntax here at all — it only decides what the result is typed as, and that is where things get interesting.
What spread replaced
Every one of those three jobs had a method call before the dots arrived, and seeing the old form side by side explains why spread caught on so fast.
const first = [1, 2]
const second = [3, 4]
// Before
const legacyArray = first.concat(second)
const legacyObject = Object.assign({}, { retries: 3 }, { verbose: true })
const legacyCall = Math.max.apply(null, [3, 1, 2])
// After
const modernArray = [...first, ...second]
const modernObject = { ...{ retries: 3 }, ...{ verbose: true } }
const modernCall = Math.max(...[3, 1, 2])The shorter form is the smaller win. The bigger one is that spread is an expression you write inline, so it composes: you can drop a spread into the middle of a literal, mix it with normal elements, and read the whole result top to bottom without tracking a mutation.
Object.assign in particular has a trap that spread does not. It mutates its first argument, so
forgetting the empty {} writes into an object you did not mean to touch. Spread always builds
something new, which is why it pairs so well with the immutable-update patterns that state
libraries expect.
What TypeScript infers from an array spread
Spreading an array into another array widens to the union of both element types. No manual annotation needed, and no surprises:
const ids = [1, 2, 3]
const withExtra = [...ids, 4] // number[]
const labels = ['a', 'b']
const combined = [...ids, ...labels] // (string | number)[]That union is the useful part. If you spread two arrays that have nothing in common, you get a
union type back and the compiler forces you to narrow before you use an
element. It will not quietly hand you an any[].
Position information is the thing you lose. TypeScript knows the result holds strings and numbers, but not that the strings all sit at the end — an array type carries one element type for every index and nothing else. That single fact explains most of the spread errors further down this page.
One thing spread does drop is readonly:
const frozen: readonly number[] = [1, 2, 3]
const copy = [...frozen] // number[] — mutable again
// frozen.push(4) // ❌ Property 'push' does not exist on type 'readonly number[]'
copy.push(4) // ✅ fine, this is a fresh arrayThis is correct behaviour rather than a bug. Spreading builds a brand new array, and nobody else holds a reference to it, so there is nothing to protect.
Spread works on any iterable, not just arrays
The array form does not actually require an array. Anything iterable works, which makes spread the
shortest way to turn a Set, a Map, or a string into a real array you can call array methods on:
const unique = new Set(['a', 'b', 'a'])
const uniqueList = [...unique] // string[] with two entries
const letters = [...'hello'] // string[] — ['h', 'e', 'l', 'l', 'o']
const entries = [...new Map([['id', 1]])] // [string, number][]Deduplicating an array is the pattern you will reach for most: wrap it in a Set, spread it back
out, and the result is typed exactly like the input. Note that objects are not iterable, so the
array form only accepts iterables while the object form only accepts objects — the two are not
interchangeable.
Object spread: the last one wins
For objects, order is the whole rule. Properties are applied left to right, and a later key overwrites an earlier one.
type Config = { retries: number; verbose: boolean }
const defaults: Config = { retries: 3, verbose: false }
const overrides = { verbose: true }
const config = { ...defaults, ...overrides }
console.log(config.verbose) // true — the override landedFlip the two spreads and you get a bug that type-checks perfectly:
const flippedDefaults = { retries: 3, verbose: false }
const flippedOverrides = { verbose: true }
const broken = { ...flippedOverrides, ...flippedDefaults }
console.log(broken.verbose) // false — the defaults clobbered the overrideBoth objects have the same shape, so the types are identical and the compiler has nothing to complain about. Defaults go first, overrides go last. Every time.
TypeScript tracks the precedence in the resulting type too. Spread an object whose value is a
string and then one whose value is a number, and the result is a number — not string | number, and not an error:
const stringy = { value: 'hello' }
const numeric = { value: 42 }
const winner = { ...stringy, ...numeric }
const doubled = winner.value * 2 // value is numberSpread is not an intersection
That last example is exactly where object spread parts ways with intersection types. An intersection of two conflicting properties gives you a type nothing can satisfy:
type Stringy = { value: string }
type Numeric = { value: number }
type Impossible = Stringy & Numeric
// Impossible['value'] is string & number — a type with no values
// const nope: Impossible = { value: 'hello' }
// ❌ Type 'string' is not assignable to type 'string & number'Spread resolves the conflict by picking a winner. Intersection refuses to pick and hands you a dead type. If you are modelling "merge these two shapes" in the type system, that difference decides which tool you reach for.
One more object-spread quirk worth knowing: spreading into a typed variable skips excess property checks.
type Point = { x: number; y: number }
const source = { x: 1, y: 2, z: 3 }
const point: Point = { ...source } // ✅ no complaint about z
// const literal: Point = { x: 1, y: 2, z: 3 }
// ❌ Object literal may only specify known properties, and 'z' does not exist in type 'Point'Writing z inline is an error. Spreading it in is not, because the freshness that triggers excess
property checking is lost through the spread. The extra key still exists at runtime.
Spreading into a function call
This is where most people meet their first real spread error. Take a function with two fixed parameters:
function createUser(name: string, age: number) {
return { name, age }
}
const args = ['Ada', 36] // (string | number)[]
// createUser(...args)
// ❌ TS2556: A spread argument must either have a tuple type or be passed to a rest parameterThe array is typed (string | number)[]. TypeScript knows neither how long it is nor what sits at
each index, so it cannot check the call. args[0] might be a number as far as the type system is
concerned.
The fix is to give the compiler a length and a per-position type — which is exactly what a tuple is:
function createTupleUser(name: string, age: number) {
return { name, age }
}
const tupleArgs: [string, number] = ['Ada', 36]
createTupleUser(...tupleArgs) // ✅Or let inference do it with as const, which locks both the length and the literal types:
function createConstUser(name: string, age: number) {
return { name, age }
}
const constArgs = ['Ada', 36] as const // readonly ['Ada', 36]
createConstUser(...constArgs) // ✅If you hit this in real code, TS2556 has the full breakdown of every way the error shows up and how to fix each one.
Rest parameters do not need a tuple
The error message mentions a second escape hatch, and it is the easier one. A rest parameter accepts any array of the right element type, because there is no fixed arity to check:
function logAll(...items: string[]) {
items.forEach((item) => console.log(item))
}
const words = ['first', 'second', 'third']
logAll(...words) // ✅ plain string[] is fine hereSpread and rest are the same three dots pointing in opposite directions. In a call site, spread expands a list into arguments. In a parameter list, rest collects arguments into a list.
Typing a reusable merge helper
Once you wrap spread in a helper, you need generics to keep the argument
types alive. Without them the helper returns object and you have thrown away everything the
caller knew:
function merge<A extends object, B extends object>(a: A, b: B): A & B {
return { ...a, ...b }
}
const account = merge({ id: 1 }, { name: 'Ada' })
console.log(account.id, account.name) // both typed, no castingNote the return type is A & B even though runtime spread has last-one-wins semantics. For
disjoint shapes — the normal case for a merge helper — the two agree. For overlapping keys they do
not, and the intersection will describe a value your function cannot actually produce. Keep helpers
like this for merging unrelated objects, and spread inline when keys overlap.
Spread copies one level deep
The caveat that bites hardest in production has nothing to do with types.
const original = { name: 'Ada', tags: ['typescript'] }
const shallow = { ...original }
shallow.tags.push('javascript')
console.log(original.tags) // ['typescript', 'javascript']The outer object is new. The tags array is the same array, shared by both. TypeScript will not
warn you, because nothing about that code is type-unsafe — it is just not the copy you assumed you
had.
Spread copies property values, and the value of an object-typed property is a reference. One level down, both objects point at the same thing. This is the source of the classic "I cloned the state and it still mutated" bug, and it gets harder to spot as objects nest deeper, because the top two or three levels often do behave like a real copy.
For a real deep copy, use structuredClone:
const deepSource = { name: 'Ada', tags: ['typescript'] }
const deep = structuredClone(deepSource)
deep.tags.push('javascript')
console.log(deepSource.tags) // ['typescript'] — untouchedstructuredClone handles dates, maps, sets, and cycles, but it throws on functions and class
instances lose their prototype. For plain data it is the right default.
Summary
The spread operator itself is three dots and a single rule: unpack the thing on the right into the place you wrote it. Everything worth remembering sits in what TypeScript makes of that.
- Array spread unions the element types and drops
readonly. - Object spread applies left to right — defaults first, overrides last.
- Overlapping keys resolve to the last one, which is not what an intersection does.
- Spreading into fixed parameters needs a tuple or
as const; rest parameters take any array. - The copy is shallow. Reach for
structuredClonewhen it needs not to be.
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