The JavaScript Spread Operator in TypeScript

September 2, 202610 min read
Requirements:
ArraysObjectsFunctions

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 array

This 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 landed

Flip 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 override

Both 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 number

Spread 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 parameter

The 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 here

Spread 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 casting

Note 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'] — untouched

structuredClone 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.

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 the javascript spread operator in typescript knowledge to the test with these related challenges.

#533Concat
Easy
#3057Push
Easy
#3060Unshift
Easy
#191Append Argument
Medium
#599Merge
Medium
#27932MergeAll
Medium

Related Concepts

Concepts that build on or relate to the javascript spread operator in typescript.

TypeScript TuplesIntersection TypesTypeScript GenericsUnion Types