Destructuring and Spread

Article · 10:00

Two pieces of syntax that show up in nearly every modern JavaScript file. Both are about moving values in and out of shapes without a pile of temporary variables - including defaults, renaming and the rest pattern.

This lesson has 11 runnable examples. Press Try yourself! under any of them to open it in the editor.

Destructuring and Spread

Destructuring pulls values out of a structure by writing the shape of it. Spread puts them back. Both are syntax rather than functions, which is why they work in places a function could not - parameter lists, imports, assignments.

Destructuring, in every position it works

JS Example 1 12 lines
const user = { id: 7, name: 'Aisha', address: { city: 'Leeds', post: 'LS1' } };

const { name } = user;                       // 'Aisha'
const { name: fullName } = user;             // rename
const { nickname = 'none' } = user;          // default for undefined only
const { address: { city } } = user;          // nested
const { id, ...rest } = user;                // rest: everything else

const [first, second] = [10, 20, 30];        // by position
const [, , third] = [10, 20, 30];            // skip with a hole
const [head, ...tail] = [10, 20, 30];        // rest, in an array
const [a = 1, b = 2] = [undefined];          // a is 1, b is 2

Open this example in the editor and change it. Nothing you type is saved.

The default applies only when the value is undefined. A stored null passes straight through, which is usually right and occasionally surprising:

JS Example 2 2 lines
const { timeout = 30 } = { timeout: null };
console.log(timeout);        // null, not 30

Open this example in the editor and change it. Nothing you type is saved.

If you want null to fall back too, destructure and then use ??, or do not store null for "not set" in the first place.

Where it earns its keep: function parameters

JS Example 3 14 lines
// Without: four arguments whose order nobody remembers.
function connectOld(host, port, timeout, retries) {}
connectOld('db.local', 3306, 30, 3);

// With: named at the call site, defaulted at the definition, and
// reorderable without breaking anything.
function connect({ host, port = 3306, timeout = 30, retries = 3 } = {}) {
  return host + ':' + port + ' (timeout ' + timeout + ', retries ' + retries + ')';
}

console.log(connect({ host: 'db.local' }));
// 'db.local:3306 (timeout 30, retries 3)'
console.log(connect({ host: 'db.local', retries: 0 }));
// 'db.local:3306 (timeout 30, retries 0)'

Open this example in the editor and change it. Nothing you type is saved.

The = {} on a destructured parameter

That trailing = {} is not decoration. Without it, calling the function with no arguments throws.

JS Example 4 6 lines
function bad({ a }) { return a; }
function good({ a } = {}) { return a; }

// bad();          // TypeError: Cannot destructure property 'a' of
                   // 'undefined' as it is undefined.
console.log(good());    // undefined - no throw

Open this example in the editor and change it. Nothing you type is saved.

The reason is mechanical: destructuring reads properties off the value, and undefined has no properties. The = {} supplies something to read from. Any function whose options object is optional needs it, and forgetting it is the single most common destructuring bug.

Spread is shallow. Every time.

JS Example 5 5 lines
const defaults = { retries: 3, headers: { accept: 'json' } };
const config = { ...defaults, retries: 5 };

config.headers.accept = 'text';
console.log(defaults.headers.accept);        // 'text' - shared object

Open this example in the editor and change it. Nothing you type is saved.

The top level was copied. headers was not - the new object holds the same reference. This is the same shallowness as everywhere else in the language, and the fixes are the same: structuredClone, or spread each level you care about.

JS Example 6 1 lines
const safe = { ...defaults, headers: { ...defaults.headers } };

Open this example in the editor and change it. Nothing you type is saved.

Order matters, and later wins:

JS Example 7 4 lines
const defaults = { retries: 3, headers: { accept: 'json' } };

console.log({ ...defaults, retries: 5 });    // retries 5
console.log({ retries: 5, ...defaults });    // retries 3 - defaults won

Open this example in the editor and change it. Nothing you type is saved.

Put the spread first when you are overriding, last when you are supplying a floor. Getting this backwards produces a settings object that ignores what the caller asked for, and it is silent.

One more edge: spread copies own enumerable properties, and it reads getters rather than copying them.

JS Example 8 13 lines
const source = {
  first: 'Aisha',
  get greeting() { return 'Hello ' + this.first; },
};
const copy = { ...source };

source.first = 'Noshad';
copy.first = 'Sam';

console.log(source.greeting);    // 'Hello Noshad' - still a getter
console.log(copy.greeting);      // 'Hello Aisha'  - a string, fixed at copy time
console.log(typeof Object.getOwnPropertyDescriptor(copy, 'greeting').get);
// 'undefined' - there is no getter left to call

Open this example in the editor and change it. Nothing you type is saved.

The getter became a plain string at the moment of spreading. If you need the behaviour rather than the value, use Object.create with Object.getOwnPropertyDescriptors.

Spread in arrays and calls

JS Example 9 12 lines
const a = [1, 2];
const b = [3, 4];

console.log([...a, ...b]);           // [1, 2, 3, 4] - concat, readably
console.log([0, ...a, 99]);          // [0, 1, 2, 99] - insert anywhere
console.log(Math.max(...a, ...b));   // 4 - apply, without apply

// It works on anything iterable, which is more than arrays.
console.log([...'hello']);                      // ['h','e','l','l','o']
console.log([...new Set([1, 1, 2])]);           // [1, 2]
console.log([...new Map([['a', 1]])]);          // [['a', 1]]
console.log(Object.fromEntries([['a', 1]]));    // { a: 1 }

Open this example in the editor and change it. Nothing you type is saved.

[...new Set(list)] is the idiomatic deduplicate, and it preserves insertion order. Note the asymmetry: object spread works on any object, array spread needs an iterable, so [...{ a: 1 }] throws.

Spreading into a call has a limit worth knowing: argument lists are capped at somewhere around 65,000 to 125,000 entries depending on the engine. Math.max(...hugeArray) throws RangeError: Maximum call stack size exceeded on a long enough array. Use reduce for those.

A worked example

JS Example 10 57 lines
// A settings resolver and a small immutable update helper - the two
// jobs destructuring and spread actually get used for.
const DEFAULTS = {
  host: 'localhost',
  port: 3306,
  pool: { min: 1, max: 10 },
  retries: 3,
  tags: [],
};

function resolveConfig({ pool, tags, ...flat } = {}) {
  return {
    ...DEFAULTS,
    ...flat,                              // top-level overrides
    pool: { ...DEFAULTS.pool, ...pool },  // merged one level deeper
    tags: [...DEFAULTS.tags, ...(tags ?? [])],
  };
}

const config = resolveConfig({ host: 'db.local', pool: { max: 50 }, tags: ['live'] });
console.log(config);
// { host: 'db.local', port: 3306, pool: { min: 1, max: 50 },
//   retries: 3, tags: ['live'] }

// DEFAULTS is untouched, which is the whole point of the explicit
// per-level spread.
console.log(DEFAULTS.pool);        // { min: 1, max: 10 }
console.log(DEFAULTS.tags);        // []

// Immutable update of one row in a list, by id.
const update = (rows, id, changes) =>
  rows.map((row) => (row.id === id ? { ...row, ...changes } : row));

const rows = [
  { id: 1, name: 'first', done: false },
  { id: 2, name: 'second', done: false },
];
const next = update(rows, 2, { done: true });

console.log(rows[1].done);          // false - original intact
console.log(next[1].done);          // true
console.log(next[0] === rows[0]);   // true - unchanged rows are shared

// Swapping, which needs no temporary variable.
let x = 1, y = 2;
[x, y] = [y, x];
console.log(x, y);                  // 2 1

// Destructuring in a loop header, which is where you will use it most.
const entries = Object.entries({ a: 1, b: 2 });
for (const [key, value] of entries) {
  console.log(key + '=' + value);   // 'a=1' then 'b=2'
}

// And a rest parameter, which is a real array - unlike arguments.
const tally = (label, ...amounts) => label + ': ' + amounts.reduce((p, q) => p + q, 0);
console.log(tally('total', 1, 2, 3));   // 'total: 6'

Open this example in the editor and change it. Nothing you type is saved.

resolveConfig is worth reading twice. The parameter list pulls pool and tags out by name precisely because they need deeper handling, and ...flat collects everything that can be merged at the top level. The shape of the merge is visible in the shape of the parameter.

Where destructuring reads badly

It is syntax, so it can be nested arbitrarily, and arbitrarily nested is unreadable:

JS Example 11 10 lines
const response = { data: { items: [{ meta: { tags: ['php'] } }] } };

// Technically valid. Do not write this.
const { data: { items: [{ meta: { tags: [tagA = 'none'] = [] } = {} } = {}] = [] } = {} } = response;

// Two readable lines instead.
const firstItem = response.data?.items?.[0];
const tagB = firstItem?.meta?.tags?.[0] ?? 'none';

console.log(tagA, tagB);    // 'php' 'php' - same answer, one of them legible

Open this example in the editor and change it. Nothing you type is saved.

The rule of thumb: one level of nesting is clear, two is borderline, three means use optional chaining instead. Destructuring is for naming things you are about to use, not for navigating.

When it goes wrong

Symptom Cause
Cannot destructure property of undefined Missing = {} on an optional options parameter
A default did not apply The value was null, not undefined
A caller's override was ignored The spread came after the override
Changing a copy changed the original Spread is shallow; copy each nested level
A getter stopped updating after copying Spread reads getters into plain values
[...obj] threw is not iterable Array spread needs an iterable; use Object.entries
RangeError from fn(...list) Too many arguments; use reduce or chunk it
A renamed variable is undefined { a: b } renames; { a, b } takes two properties

A check you can run

Take resolveConfig and delete the pool: line from the returned object, leaving only the two spreads. Then call it with { pool: { max: 50 } } and print the result.

pool becomes { max: 50 } - min has vanished, because the top-level spread replaced the whole nested object rather than merging into it. That one deleted line is the difference between a shallow copy and a correct one, and seeing it fail is more memorable than reading that it would.