Destructuring and Spread: Spec Semantics and Edge Behaviors
Chapter 32: Destructuring and Spread Syntax โ Desugaring from the Spec's Perspective
Destructuring looks like convenient syntax, but the spec defines it as a series of precise iterator operations โ understanding this explains why destructuring
nullthrows butundefinedtriggers defaults instead.
Core question of this chapter: What desugaring process does destructuring assignment undergo at the spec level, what different spec operations do spread syntax correspond to in array, function call, and object contexts, and under what conditions does V8 deoptimize destructuring patterns?
After reading this chapter you will understand:
- The complete IteratorDestructuringAssignmentEvaluation flow for array destructuring
- The RequireObjectCoercible + GetValue call chain for object destructuring
- Evaluation order for nested destructuring (left-to-right, depth-first) and its observability
- The different spec operations that spread syntax (
...) maps to in its three contexts - The behavioral difference between
undefinedandnulldefault values and the spec basis for it
Level 1 ยท What You Need to Know (1-3 Years Experience)
Destructuring assignment, introduced in ES2015 (ES6), lets you extract values from arrays or objects with more concise syntax. But it's not magic โ behind every line of destructuring code is a series of concrete operations executed by the engine.
Array Destructuring Basics
// Basic array destructuring
const [a, b, c] = [1, 2, 3]
// a = 1, b = 2, c = 3
// Skip elements (using holes)
const [first, , third] = [1, 2, 3]
// first = 1, third = 3
// Rest pattern (remaining elements)
const [head, ...tail] = [1, 2, 3, 4, 5]
// head = 1, tail = [2, 3, 4, 5]
// Default values
const [x = 10, y = 20] = [1]
// x = 1, y = 20 (undefined triggers default, not null!)
// Variable swapping
let m = 1, n = 2
;[m, n] = [n, m]
// m = 2, n = 1
Object Destructuring Basics
// Basic object destructuring
const { name, age } = { name: 'Alice', age: 30 }
// name = 'Alice', age = 30
// Renaming (aliases)
const { name: userName, age: userAge } = { name: 'Bob', age: 25 }
// userName = 'Bob', userAge = 25
// Default values
const { role = 'user', level = 1 } = { role: 'admin' }
// role = 'admin', level = 1
// Rest pattern (remaining properties)
const { a: first2, ...rest } = { a: 1, b: 2, c: 3 }
// first2 = 1, rest = { b: 2, c: 3 }
// Nested destructuring
const { address: { city, country = 'CN' } } = { address: { city: 'Beijing' } }
// city = 'Beijing', country = 'CN'
Spread Syntax Basics
// Spread in array literals
const arr1 = [1, 2, 3]
const arr2 = [0, ...arr1, 4, 5] // [0, 1, 2, 3, 4, 5]
// Spread in function calls
function sum(a, b, c) { return a + b + c }
const nums = [1, 2, 3]
sum(...nums) // 6, equivalent to sum(1, 2, 3)
// Spread in object literals (ES2018)
const defaults = { color: 'red', size: 'M' }
const custom = { ...defaults, color: 'blue' }
// { color: 'blue', size: 'M' } (later properties override earlier ones)
Most Common Misconceptions
Misconception 1: null and undefined behave differently for default values
const { x = 10 } = { x: null }
console.log(x) // null! NOT 10!
const { y = 10 } = { y: undefined }
console.log(y) // 10! undefined triggers the default value
// Spec definition: only undefined triggers destructuring default values
// null is a valid value and does not trigger defaults
Misconception 2: Rest elements must be last
const [...first, last] = [1, 2, 3] // SyntaxError! rest must be last
const [first2, ...middle, last2] = [1, 2, 3] // SyntaxError! rest must be last
// Correct:
const [first3, ...rest3] = [1, 2, 3] // first3 = 1, rest3 = [2, 3]
Misconception 3: Destructuring null or undefined objects
const { a: x2 } = null // TypeError: Cannot destructure property 'a' of 'null'
const { b: y2 } = undefined // TypeError: Cannot destructure property 'b' of 'undefined'
// This is not a bug โ it's by design: object destructuring requires right side to not be null or undefined
Level 2 ยท How It Works (3-5 Years Experience)
Desugaring Process for Array Destructuring
Array destructuring is equivalent to the following operations (using const [a, b] = expr as an example):
// Spec-equivalent code (pseudocode)
const _iter = expr[Symbol.iterator]() // Step 1: get iterator
let _result
// Destructure a
_result = _iter.next() // Step 2: call next()
const a = _result.done ? undefined : _result.value // Step 3: get value
// Destructure b
_result = _iter.next() // Step 4: call next() again
const b = _result.done ? undefined : _result.value // Step 5: get value
// If there's a rest pattern [...rest], call remaining next() calls and collect results
This means:
- Array destructuring consumes an iterator, not simple index access
- Any iterable object can be array-destructured (not just arrays)
- Destructuring calls
next()from left to right in sequence
// Proof: array destructuring consumes an iterator
function* gen() {
console.log('yield 1')
yield 1
console.log('yield 2')
yield 2
console.log('yield 3')
yield 3
}
const [x, , z] = gen() // hole skips yield 2, but next() is still called!
// Output:
// yield 1
// yield 2 โ the hole still consumed the iterator!
// yield 3
// x = 1, z = 3
// Array destructuring works with all iterables
const [a2, b2] = new Set([1, 2, 3]) // a2 = 1, b2 = 2
const [c2, d2] = 'hello' // c2 = 'h', d2 = 'e'
const [e2, f2] = new Map([[1, 'a'], [2, 'b']])
// e2 = [1, 'a'], f2 = [2, 'b']
Desugaring Process for Object Destructuring
Object destructuring is equivalent to the following operations (using const { a, b } = expr as an example):
// Spec-equivalent code (pseudocode)
const _obj = Object(expr) // Step 1: RequireObjectCoercible + ToObject
const a = _obj.a // Step 2: GetValue(_obj, 'a')
const b = _obj.b // Step 3: GetValue(_obj, 'b')
Key points:
Object(expr)converts primitive values to their object wrappers (Object(1)โNumber {1})nullandundefinedcannot be converted byObject(), so a TypeError is thrown immediately
// Primitive values can be object-destructured (via wrappers accessing methods)
const { toString } = 42 // equivalent to Object(42).toString
const { length } = 'hello' // length = 5
const { toFixed } = 3.14 // get method reference
// null/undefined cannot
const { x: x3 } = 0 // โ, 0 is wrapped as a Number object
const { y: y3 } = '' // โ, '' is wrapped as a String object
const { z: z3 } = null // โ, TypeError
When Default Values Are Evaluated
Default values are lazily evaluated โ they're only evaluated when the value is undefined:
let count = 0
function getDefault() {
count++
return 'default'
}
const { a: a3 = getDefault(), b: b3 = getDefault() } = { a: 'actual', b: undefined }
// a3 = 'actual', getDefault() not called
// b3 = 'default', getDefault() called once
console.log(count) // 1 (not 2)
This feature is important for default values with side effects (like object creation, API calls).
Evaluation Order in Nested Destructuring
Nested destructuring uses depth-first, left-to-right evaluation order:
// Observe the execution order of nested destructuring
const obj = {
get a() { console.log('accessing a'); return { get x() { console.log('accessing x'); return 1 } } },
get b() { console.log('accessing b'); return 2 }
}
const { a: { x }, b } = obj
// Output order:
// accessing a
// accessing x
// accessing b
This means the iterator call sequence in array destructuring is completely predictable:
// Iterator consumption order in nested array destructuring
const [[a4, b4], [c4, d4]] = [[1, 2], [3, 4]]
// Inner array [1, 2] is iterated first, then [3, 4]
Three Contexts for Spread Syntax
Spread syntax ... maps to different spec operations in different positions:
Context Spec Operation Semantics
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Array literal SpreadElement โ IterableToList Copy all iterator values
Function call ArgumentList โ ArgumentListEvaluation Expand as individual arguments
Object literal PropertyDefinitionEvaluation Copy all enumerable own properties
// Spread in array literals: uses Symbol.iterator
const set = new Set([1, 2, 3])
const arr = [...set] // correct, because Set implements Symbol.iterator
// equivalent to Array.from(set)
// Spread in function calls: argument must be iterable
Math.max(...[1, 2, 3]) // 3
// Note: Math.max.apply(null, [1, 2, 3]) is the old equivalent
// Spread in object literals: uses [[OwnPropertyKeys]] + [[Get]]
// Does NOT use Symbol.iterator!
const proto = { inherited: true }
const child = Object.create(proto)
child.own = true
const spread = { ...child }
// spread = { own: true } (does not include inherited property)
Object Spread vs Object.assign
// Object.assign triggers setters
const target = {
set x(v) { console.log('setter called', v) }
}
Object.assign(target, { x: 1 }) // Output: setter called 1
// Object spread doesn't trigger setters (creates new object, doesn't assign to existing)
const spread2 = { ...{ x: 1 } } // no setter triggered
// Object.assign modifies the target; spread creates a new object
const obj2 = { a: 1 }
Object.assign(obj2, { b: 2 }) // obj2 is modified
const obj3 = { ...obj2, c: 3 } // obj2 is not modified, new object created
Level 3 ยท How the Spec Defines It (Senior Developers)
Spec Evaluation of ArrayAssignmentPattern
In the ECMAScript spec, the core algorithm for array destructuring assignment is DestructuringAssignmentEvaluation(value) for ArrayAssignmentPattern : [ AssignmentElementList ]:
13.1.3 Runtime Semantics: DestructuringAssignmentEvaluation
ArrayAssignmentPattern : [ AssignmentElementList ]
- Let
iteratorRecordbe ?GetIterator(value, sync).- Let
resultbeIteratorDestructuringAssignmentEvaluationofAssignmentElementListwith argumentiteratorRecord.- If
resultis an abrupt completion, then a. IfiteratorRecord.[[Done]]is false, return ?IteratorClose(iteratorRecord, result). b. Return ?result.- If
iteratorRecord.[[Done]]is false, return ?IteratorClose(iteratorRecord, NormalCompletion(undefined)).- Return
result.
Commentary:
Step 1: GetIterator(value, sync) calls value[Symbol.iterator](), returning an iteratorRecord (containing the iterator object and [[Done]] state).
Step 2: IteratorDestructuringAssignmentEvaluation processes each destructuring element left-to-right, calling next().
Steps 3-4: Whether destructuring succeeds or fails, if the iterator is not yet done ([[Done]] is false), IteratorClose is called to clean up iterator resources (calling the iterator's return() method).
An important spec detail: IteratorClose is called even when destructuring fails (step 3), meaning a custom iterator's return() method is still called when destructuring throws an exception:
function* gen2() {
try {
yield 1
yield 2
} finally {
console.log('iterator cleanup called')
}
}
try {
const [a5] = gen2()
// After gen2 yields 1, destructuring completes, iterator is closed
// Output: iterator cleanup called (IteratorClose triggers generator's finally)
} catch (e) {}
Spec Evaluation of ObjectAssignmentPattern
Object destructuring assignment corresponds to the evaluation of ObjectAssignmentPattern:
13.1.3 Runtime Semantics: DestructuringAssignmentEvaluation
ObjectAssignmentPattern : { AssignmentPropertyList }
- Perform ?
RequireObjectCoercible(value).- Perform ?
PropertyDestructuringAssignmentEvaluationofAssignmentPropertyListwith argumentvalue.- Return
undefined.
RequireObjectCoercible(value) definition (ยง7.2.1):
If
argumentis undefined, throw a TypeError exception. Ifargumentis null, throw a TypeError exception. Returnargument.
This is why destructuring null and undefined throws TypeError โ not because null can't have properties (object wrappers handle primitives), but because the spec rejects them at the very first step.
For each property, the spec then executes KeyedDestructuringAssignmentEvaluation:
- Let
vbe ?GetV(value, propertyName).- If
Initializeris present andvisundefined, then a. LetdefaultValuebe the result of evaluatingInitializer. b. Setvto ?GetValue(defaultValue).- Return ?
DestructuringAssignmentTargetwithv.
The difference between GetV(value, propertyName) and ordinary [[Get]]: GetV temporarily wraps primitives before accessing properties; [[Get]] is only for objects. This gives the spec a clear operational definition for primitive property access like 'hello'.length.
Step 2 makes it explicit: only when the retrieved value v is undefined is the default value evaluated and used. This is the spec basis for why null doesn't trigger defaults.
Spec Operations for Spread Syntax
Spread in array literals (SpreadElement):
The spec defines ...iterable in [...iterable] as:
SpreadElement : ... AssignmentExpression
- Let
spreadRefbe the result of evaluatingAssignmentExpression.- Let
spreadObjbe ?GetValue(spreadRef).- Let
iteratorRecordbe ?GetIterator(spreadObj, sync).- Return ?
IteratorToList(iteratorRecord).
Spread in function calls (ArgumentList):
ArgumentList : ArgumentList , ... AssignmentExpression
- Let
precedingArgsbe the result of evaluatingArgumentList.- Let
spreadRefbe the result of evaluatingAssignmentExpression.- Let
iteratorRecordbe ?GetIterator(? GetValue(spreadRef), sync).- Repeat, a. Let
nextbe ?IteratorStep(iteratorRecord). b. Ifnextis false, returnprecedingArgs. c. LetnextArgbe ?IteratorValue(next). d. AppendnextArgtoprecedingArgs.
Spread in function calls appends arguments one by one, not converting to an array all at once. This means spread arguments count in the function's arguments object:
function f2(...args) { return args }
const nums2 = [1, 2, 3]
f2(0, ...nums2, 4) // [0, 1, 2, 3, 4]
Spread in object literals (CopyDataProperties):
PropertyDefinition : ... AssignmentExpression
- Let
exprValuebe the result of evaluatingAssignmentExpression.- Let
fromValuebe ?GetValue(exprValue).- Let
excludedNamesbe a new empty List.- Return ?
CopyDataProperties(object, fromValue, excludedNames).
CopyDataProperties definition (ยง19.1.3.2):
- If
sourceis undefined or null, returntarget.- Let
frombe !ToObject(source).- Let
keysbe ?from.[[OwnPropertyKeys]]().- For each element
nextKeyofkeys, do a. Letdescbe ?from.[[GetOwnProperty]](nextKey). b. Ifdescis not undefined anddesc.[[Enumerable]]is true, then i. LetpropValuebe ?Get(from, nextKey). ii. Perform !CreateDataPropertyOrThrow(target, nextKey, propValue).- Return
target.
Note step 1: CopyDataProperties does not throw when source is undefined or null โ it simply returns the target object. This differs from object destructuring which throws TypeError immediately:
const { ...a6 } = null // TypeError (object destructuring's RequireObjectCoercible)
const b6 = { ...null } // {} (object spread's CopyDataProperties allows null)
const c6 = { ...undefined } // {} (object spread allows undefined)
Level 4 ยท Edge Cases and Traps (All Experience Levels)
Trap 1: Destructuring and Iterator Side Effects
Because array destructuring works through iterators, side effects in iterators are triggered precisely:
// Trap: holes also consume the iterator
let sideEffect = 0
function* sideEffectGen() {
while (true) {
sideEffect++
yield sideEffect
}
}
const [, , third2] = sideEffectGen()
// sideEffect is called 3 times, not 1 time
console.log(sideEffect) // 3
// Real-world scenario: destructuring a database cursor with side effects
// Skipping the first two records still runs your side effects (logging, state updates) 3 times
Trap 2: Default Value Evaluation Timing in Function Parameters
// Trap: destructuring defaults in function parameters
function process({ data = fetchData(), timeout = 3000 } = {}) {
return { data, timeout }
}
// When is fetchData() called?
process({ timeout: 5000 })
// fetchData() IS called! because the data property is undefined
process({ data: cachedData, timeout: 5000 })
// fetchData() is NOT called! because data has a value
This trap is common in React component props destructuring:
// Dangerous pattern: every render, if items is not passed, createDefaultItems() is called
function MyList({ items = createDefaultItems(), className = '' } = {}) {
return items.map(item => <li className={className}>{item}</li>)
}
// Safer pattern: use useMemo or handle defaults inside the function body
function MyList({ items, className = '' } = {}) {
const defaultItems = useMemo(() => items || createDefaultItems(), [items])
return defaultItems.map(item => <li className={className}>{item}</li>)
}
Trap 3: Property Enumeration Order in Object Rest Destructuring
// Rest collects properties following the [[OwnPropertyKeys]] order spec
const obj4 = {}
obj4.z = 3
obj4.a = 1
obj4.b = 2
const { z, ...remaining } = obj4
// The property order of remaining follows [[OwnPropertyKeys]] return order
// Spec guarantees: integer indices in ascending order, string keys in creation order, Symbols last
// So remaining = { a: 1, b: 2 } (z excluded, a and b in creation order)
// This trap is especially insidious during JSON serialization:
const original = { b: 2, a: 1, c: 3 }
const { a: extracted, ...withoutA } = original
JSON.stringify(withoutA) // '{"b":2,"c":3}', a excluded
// Using delete:
const copy = { ...original }
delete copy.a
JSON.stringify(copy) // '{"b":2,"c":3}', same result
// Both produce the same result, but rest destructuring more clearly expresses
// the intent of "remove property a"
Trap 4: Partial State After Nested Destructuring Failure
// When nested destructuring fails, already-destructured variables are already assigned
const [a7, [b7, c7], d7] = [1, null, 4]
// TypeError: null is not iterable
// But a7 has already been assigned 1!
// b7 and c7 are not assigned (TDZ)
// d7 is not assigned (TDZ)
// With const this creates a confusing error state:
// a7 is declared and assigned, but other variables in the same declaration statement didn't complete
// Even more dangerous in assignment patterns (not declarations)
let a8, b8, c8
try {
;[a8, [b8, c8]] = [1, null]
} catch (e) {
console.log(a8, b8, c8) // 1, undefined, undefined
// a8 was assigned! b8 and c8 were not
}
V8 Optimization and Deoptimization for Destructuring
V8 performs inline optimization for specific destructuring patterns, but the following situations trigger deoptimization:
- Unstable object shape:
// Optimized scenario: objects being destructured have the same hidden class
function process2(obj) {
const { x, y } = obj
return x + y
}
// Calling process2({ x: 1, y: 2 }) 100 times โ V8 inline optimization
// Then calling process2({ x: 1, y: 2, z: 3 }) โ triggers deoptimization (shape changed)
- Holey arrays in array destructuring:
const arr2 = [1, , 3] // Holey array
const [a9, b9, c9] = arr2
// V8 processes Holey Arrays ~2-5x slower than Packed Arrays
// b9 = undefined, but the internal processing path differs from Packed Arrays
- Destructuring objects with getters:
const obj5 = { get x() { return Math.random() } }
const { x: x4 } = obj5
// Every access to x calls the getter, V8 cannot cache the result
// Not a bug, but frequently destructuring getter-heavy objects on hot paths
// causes performance drops of up to 10x
Measured performance (Node.js 20, M1 MacBook Pro):
- Destructuring Packed Array
[1,2,3]: ~8ns/operation - Destructuring Holey Array
[1,,3]: ~18ns/operation (2.25x slower) - Destructuring object with getter: ~45ns/operation (5.6x slower)
- Direct property access
obj.x: ~3ns/operation
How Variable Swapping Works
let p = 1, q = 2
;[p, q] = [q, p]
This isn't magic โ the spec's evaluation order guarantees:
- Right side
[q, p]is evaluated first โ[2, 1](temporary array created) - Left side is destructured: p = 2, q = 1
// Manual equivalent
const _temp = [q, p] // create temporary array, q = 2, p = 1 at this point
p = _temp[0] // p = 2
q = _temp[1] // q = 1
This is safer than classic XOR swap (p ^= q; q ^= p; p ^= q) โ no integer overflow limitations โ but creates a temporary array object (~56 bytes of V8 heap memory). For extremely performance-sensitive hot paths, a temporary variable approach (const tmp = p; p = q; q = tmp) is faster because V8 can optimize tmp as a stack variable.
Chapter Summary
-
Array destructuring is iterator consumption, not index access: destructuring consumes an iterator (calls
next()), any object implementingSymbol.iteratorcan be array-destructured, and holes also consume the iterator (but discard the value). -
Object destructuring uses RequireObjectCoercible:
nullandundefinedare rejected at the first step; primitives (numbers, strings) access properties through object wrappers; onlyundefinedvalues trigger defaults โnulldoes not. -
Spread syntax maps to different operations in three contexts: array literals use
GetIterator(consuming iterators), function calls useArgumentListEvaluation(appending arguments one by one), object literals useCopyDataProperties(copying enumerable own properties, allowing null/undefined source). -
Nested destructuring is depth-first, left-to-right evaluation: intermediate failures leave some variables assigned and others not, which is especially dangerous in declaration statements.
-
V8 destructuring optimization depends on stable object shapes: object shape changes, holey arrays, and objects with getters all trigger deoptimization โ avoid frequently destructuring shape-unstable objects on hot paths.