Chapter 32

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 null throws but undefined triggers 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:


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:

  1. Array destructuring consumes an iterator, not simple index access
  2. Any iterable object can be array-destructured (not just arrays)
  3. 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:

// 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 ]

  1. Let iteratorRecord be ? GetIterator(value, sync).
  2. Let result be IteratorDestructuringAssignmentEvaluation of AssignmentElementList with argument iteratorRecord.
  3. If result is an abrupt completion, then a. If iteratorRecord.[[Done]] is false, return ? IteratorClose(iteratorRecord, result). b. Return ? result.
  4. If iteratorRecord.[[Done]] is false, return ? IteratorClose(iteratorRecord, NormalCompletion(undefined)).
  5. 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 }

  1. Perform ? RequireObjectCoercible(value).
  2. Perform ? PropertyDestructuringAssignmentEvaluation of AssignmentPropertyList with argument value.
  3. Return undefined.

RequireObjectCoercible(value) definition (§7.2.1):

If argument is undefined, throw a TypeError exception. If argument is null, throw a TypeError exception. Return argument.

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:

  1. Let v be ? GetV(value, propertyName).
  2. If Initializer is present and v is undefined, then a. Let defaultValue be the result of evaluating Initializer. b. Set v to ? GetValue(defaultValue).
  3. Return ? DestructuringAssignmentTarget with v.

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

  1. Let spreadRef be the result of evaluating AssignmentExpression.
  2. Let spreadObj be ? GetValue(spreadRef).
  3. Let iteratorRecord be ? GetIterator(spreadObj, sync).
  4. Return ? IteratorToList(iteratorRecord).

Spread in function calls (ArgumentList):

ArgumentList : ArgumentList , ... AssignmentExpression

  1. Let precedingArgs be the result of evaluating ArgumentList.
  2. Let spreadRef be the result of evaluating AssignmentExpression.
  3. Let iteratorRecord be ? GetIterator(? GetValue(spreadRef), sync).
  4. Repeat, a. Let next be ? IteratorStep(iteratorRecord). b. If next is false, return precedingArgs. c. Let nextArg be ? IteratorValue(next). d. Append nextArg to precedingArgs.

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

  1. Let exprValue be the result of evaluating AssignmentExpression.
  2. Let fromValue be ? GetValue(exprValue).
  3. Let excludedNames be a new empty List.
  4. Return ? CopyDataProperties(object, fromValue, excludedNames).

CopyDataProperties definition (§19.1.3.2):

  1. If source is undefined or null, return target.
  2. Let from be ! ToObject(source).
  3. Let keys be ? from.[[OwnPropertyKeys]]().
  4. For each element nextKey of keys, do a. Let desc be ? from.[[GetOwnProperty]](nextKey). b. If desc is not undefined and desc.[[Enumerable]] is true, then i. Let propValue be ? Get(from, nextKey). ii. Perform ! CreateDataPropertyOrThrow(target, nextKey, propValue).
  5. 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:

  1. 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)
  1. 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
  1. 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):

How Variable Swapping Works

let p = 1, q = 2
;[p, q] = [q, p]

This isn't magic — the spec's evaluation order guarantees:

  1. Right side [q, p] is evaluated first → [2, 1] (temporary array created)
  2. 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

  1. Array destructuring is iterator consumption, not index access: destructuring consumes an iterator (calls next()), any object implementing Symbol.iterator can be array-destructured, and holes also consume the iterator (but discard the value).

  2. Object destructuring uses RequireObjectCoercible: null and undefined are rejected at the first step; primitives (numbers, strings) access properties through object wrappers; only undefined values trigger defaults — null does not.

  3. Spread syntax maps to different operations in three contexts: array literals use GetIterator (consuming iterators), function calls use ArgumentListEvaluation (appending arguments one by one), object literals use CopyDataProperties (copying enumerable own properties, allowing null/undefined source).

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

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

Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments