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
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:
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
// 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.
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.
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.
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:
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.
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
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
// 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:
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.