Destructuring and Spread
Destructuring and spread look like two topics, but they share one core mechanism: iterable and object pattern matching against values at binding time. This chapter treats them as one system, because the bugs you'll meet in practice come from where they interact — defaults that only fire on undefined, holes in array patterns, and spread's silent deep-copy assumptions.
Prerequisites
This chapter assumes you are comfortable with the material through Iteration Methods: iteration protocols, rest parameters, Object.assign semantics, and the difference between binding and assignment. You should know that const destructuring still permits mutation of the bound object.
How Patterns Bind
A destructuring pattern is not syntax sugar evaluated at runtime after the fact; the spec compiles it into a sequence of Get/Next operations with defined failure modes. The key rule to internalize:
- Defaults trigger on
undefinedonly.nulldoes not trigger a default; it binds asnull. - Array patterns use the iterator protocol, so destructuring a
Mapyields entries, and destructuring a string yields code points. - Object patterns use
Geton the target, so they work on any object (including exotic ones likeProxy), and they trigger getters.
const [a = 1, , b = 2] = [null, 'skipped'];
console.log(a, b); // null 2 — null does NOT take the default
const { x = 10 } = { x: undefined };
console.log(x); // 10
const { y = 10 } = Object.defineProperty({}, 'y', {
get() { console.log('getter fired'); return 99; },
});
// logs "getter fired", y === 99Renaming and computed keys
const { 'data-property': renamed, [dynamicKey]: alsoRenamed } = obj;Computed property names in patterns are evaluated left-to-right; a computed key that evaluates to undefined is a TypeError at binding time, not a silent miss. This is a common crash when destructuring with a key pulled from user input.
Nested Patterns and Rest
Rest elements in array destructuring create a new array; rest properties in object destructuring create a new object that copies enumerable own properties, excluding the named ones. Object rest is the only "omit keys" operation in the language that preserves key order and never invokes the omitted keys' getters on the rest copy — but the original object's getters for included keys do fire.
const user = { id: 1, name: 'Ada', password: 'hunter2', toString() { return 'u'; } };
const { password, ...safe } = user;
// safe = { id, name, toString } — own enumerable only; symbols omittedNote the failure mode: object rest silently drops symbol-keyed and non-enumerable properties. If you use rest for "strip sensitive fields," you've also stripped anything non-enumerable, which may or may not be what a downstream consumer expects. delete + structuredClone or an explicit allowlist is safer when the shape matters.
Spread Semantics and Copy Depth
Spread in object literals is CopyDataProperties: enumerable own properties, shallow. This is the single most common source of subtle bugs.
const state = { user: { name: 'Ada' }, tags: ['a'] };
const next = { ...state, user: { ...state.user } };
state.user.name = 'Grace';
console.log(next.user.name); // 'Ada' — safe only because we spread nested level
next.tags === state.tags; // true — same array referenceComparison of copy strategies:
| Technique | Depth | Preserves prototype | Handles sparse arrays | Invokes getters |
|---|---|---|---|---|
| `{...obj}` / `[...arr]` | shallow | no (Object.prototype) | no (holes become `undefined`) | yes (via Get) |
| `Object.assign` | shallow | no | n/a | yes |
| `Object.create(proto)` + assign | shallow | yes | n/a | yes |
| `structuredClone` | deep | no | yes | no (clones values) |
Sparse arrays and length
Array spread calls the iterator, so holes become undefined and trailing holes beyond the last element are dropped. Object spread on an array copies length and numeric keys as data properties — you get an array-like, not an array. Destructuring, spread, Array.from, and slice handle holes differently; never assume uniformity across them.
Argument Passing: Spread vs Rest
The same three dots mean opposite things by position:
function f(...args) { return args; } // rest: collects
const nums = [1, 2, 3];
f(...nums); // spread: expands
Math.max(...nums); // the idiomatic apply replacementFunction.prototype.apply(null, args) is now rarely needed, but note that spread with very large arrays (hundreds of thousands of elements) can exceed engine argument limits and throw. For unbounded arrays, iterate or use apply in chunks.
Also note: spread of a string spreads code points, so '👍'.length aside, [...'a👍b'] correctly yields three elements where index-based slicing would split the surrogate pair.
Choosing Between Patterns
Decision criteria:
- Immutable-update style (reducers, config layering): object spread, but audit nesting depth. One level of spread protects nothing at depth two.
- Omitting fields: rest destructuring, unless symbol/non-enumerable preservation matters.
- Variadic APIs: rest parameters, never
arguments(which is not a real array and is unavailable in arrows). - Deep copies:
structuredClone, not nested spread — nested spread is O(n²) in depth and losesDate,Map,Setidentity (spread on aMapproduces a plain object).
// Spread on a Map is a trap:
const m = new Map([['a', 1]]);
console.log({ ...m }); // { } — no own enumerable props
console.log([...m]); // [['a', 1]] — iterator works
console.log(new Map([...m, ['b', 2]])); // correct way to "extend" a MapIterables, not just arrays
Because array patterns and spread use the iterator protocol, they compose with generators and Set/Map for free:
function* ids() { yield 1; yield 2; yield 3; }
const [first, ...rest] = ids();
const unique = [...new Set([1, 1, 2, 3])]; // [1, 2, 3]One caveat: spread consumes the iterator. Spreading the same generator twice yields [] the second time — the same retention trap as it.next() idempotence.
Common Gotchas Summary
- Defaults fire on
undefinedonly; normalizenullupstream or use??. - Object rest drops symbols and non-enumerables.
- Shallow spread leaves nested references shared.
- Computed pattern keys that are
undefinedthrow. - Spreading
Map/Setinto object literals yields empty or index-keyed objects. - Iterators are single-use; spreading consumes them.
Exercise
You write const { mode = 'fast' } = config; and receive mode === null for some inputs. Why, and what are two fixes?
Show answer
Destructuring defaults fire only on undefined, not null. Fix by normalizing at the source, or change the binding to const { mode = 'fast' } = {}; const m = mode ?? 'fast'; (or bind then coalesce with ??).
Exercise
Your reducer returns { ...state, user: { ...state.user, name: action.name } } but a component reading state.user.prefs.theme still shows a stale value after prefs is replaced elsewhere. What is the mechanism?
Show answer
Spread is shallow. The outer spreads created new top-level objects, but prefs is still the same reference shared with the old state in whichever update replaced it without spreading that nesting level; the stale component is memoized against the old reference. Spread every level you replace, or use structuredClone/an immutable library for deep updates.
Exercise
When is object rest NOT equivalent to Object.assign({}, obj) followed by delete?
Show answer
Object rest copies enumerable own properties in one operation, excludes named keys, and does not invoke the omitted keys' getters; delete mutates the original (or requires a copy first), and any getters for deleted keys would already have fired during the copy. Rest also drops symbols and non-enumerables exactly like the copy — but it avoids ever calling getters for the excluded keys.
Spotted a mistake, or code that does not work?