The three dots in JavaScript do two opposite jobs. As spread, they expand something into individual pieces: array elements into a list, object properties into another object. As rest, they collect pieces back up: remaining arguments into an array, remaining properties into an object. The syntax is tiny, and that’s the trouble. A lot of behaviour is packed into ..., and most of it is invisible until a value you didn’t expect comes through.
This guide covers the cases that come up in code review and in interviews: merge order, the undefined problem, what spread drops on the floor, the difference between array and object spread, and two ways spread quietly becomes a performance or stack problem.
Merging objects: order and undefined
Spreading objects into a literal is the standard way to merge settings. Properties are copied left to right, and a later key replaces an earlier one with the same name:
const defaults = { theme: 'light', pageSize: 25, flags: { beta: false } };
const user = { pageSize: 50, theme: undefined };
console.log({ ...defaults, ...user });
// { theme: undefined, pageSize: 50, flags: { beta: false } }
console.log({ ...user, ...defaults });
// { pageSize: 25, theme: 'light', flags: { beta: false } }The first line has the order right (user settings override defaults) and still produces a broken result: theme is undefined. Spread copies every own enumerable property, and a property whose value is undefined is still a property. It overwrites 'light' just as 'dark' would.
This happens constantly with form state and partial updates. A settings form built from { theme: form.theme?.value, pageSize: ... } produces theme: undefined whenever the field is empty, and the merge wipes the default. Destructuring defaults treat undefined as missing; spread does not. If your input can contain undefined, drop those keys before merging:
const clean = Object.fromEntries(Object.entries(user).filter(([, v]) => v !== undefined));
const settings = { ...defaults, ...clean };The second line of the first example shows the other classic mistake: reversing the order so the defaults win and the user’s choices disappear. A reliable way to read it is “the last one wins”, the same as assigning properties one after another.
Key order in the result follows first insertion, not the spread that set the final value. In { ...user, ...defaults }, pageSize comes first because user introduced it, even though its value comes from defaults. That rarely matters, except in snapshot tests and JSON.stringify comparisons.
Merges are shallow
A merge replaces nested objects instead of merging into them:
const merged = { ...defaults, ...{ flags: { darkMode: true } } };
console.log(merged.flags); // { darkMode: true }flags.beta is gone, because flags was replaced as a whole value. And in the other direction, a spread copy shares nested objects with its source, so const m2 = { ...defaults }; m2.flags.beta = true changes defaults.flags.beta too. To merge one level down, spread at that level as well: { ...defaults, flags: { ...defaults.flags, ...patch.flags } }. For deep config merging with many levels, use a dedicated function and decide how arrays should combine, because there’s no single right answer.
What object spread leaves behind
Object spread copies own, enumerable properties, including symbol-keyed ones. That definition excludes more than you might expect.
Getters are called, not copied. The result gets a plain data property holding whatever the getter returned at that moment:
const withGetter = { get now() { return 'computed'; } };
const cp = { ...withGetter };
console.log(Object.getOwnPropertyDescriptor(cp, 'now'));
// { value: 'computed', writable: true, enumerable: true, configurable: true }If the getter was computing a live value, the copy is frozen at the time of the spread.
The prototype isn’t copied, so class instances turn into plain objects. Methods and getters defined on the class live on the prototype, so they vanish, and private fields aren’t own properties at all:
class Temp {
#c = 21;
get f() { return this.#c * 9 / 5 + 32; }
}
const st = { ...new Temp() };
console.log(st, st.f); // {} undefinedThis bites people who store class instances in Redux or React state and “update” them with spread. The updated object has no methods, and the next call to state.cart.total() throws TypeError: state.cart.total is not a function.
Non-enumerable properties are skipped, which is why { ...someError } loses message and stack. Symbol keys are included, which surprises people who expect spread to behave like JSON.stringify.
Setters on the target aren’t triggered. Spread defines new properties on a fresh object, while Object.assign(target, source) assigns into an existing object and will run its setters. That’s one of the few real differences between the two, along with the obvious one: Object.assign mutates its first argument.
Array spread and object spread are different operations
They look alike, but array spread uses the iteration protocol, and object spread copies properties. The difference shows up at the edges:
console.log({ ...null, ...undefined, ...'hi', ...42 });
// { '0': 'h', '1': 'i' }
const obj = { a: 1 };
[...obj]; // TypeError: obj is not iterable
[...null]; // TypeError: null is not iterableObject spread is forgiving: null and undefined contribute nothing, numbers contribute nothing, and strings contribute their characters as indexed keys. That forgiveness enables the handy pattern { ...base, ...(isAdmin && adminFields) }, where a false adds no properties.
Array spread is strict. Anything without a Symbol.iterator method throws, including plain objects, null and undefined. [...(maybeList ?? [])] is the defensive form.
Because array spread uses iteration, it works on anything iterable: [...new Set([1, 1, 2])] gives [1, 2], [...map] gives entry pairs, and strings split by code point, not by UTF-16 unit. [...'héllo👋'].length is 6, while 'héllo👋'.length is 7, because the emoji takes two UTF-16 code units. That makes spread the simple way to count user-visible characters, at least for emoji that are a single code point. Family and flag emoji built from several code points still split into pieces.
Rest parameters and rest properties
Rest collects. In a parameter list it gathers remaining arguments into a real array:
function sum(label, ...nums) {
return `${label}: ${nums.reduce((a, b) => a + b, 0)}`;
}
console.log(sum('total', 1, 2, 3), sum('none')); // total: 6 none: 0
console.log(sum.length); // 1With no extra arguments, nums is an empty array, never undefined. Unlike the old arguments object, it’s a true Array with map and reduce, and arrow functions can use it. The rest parameter must come last, and it isn’t counted in the function’s length, which matters for libraries that inspect arity (some currying helpers do).
In destructuring, rest collects the remaining elements or properties: const [head, ...tail] = [1, 2, 3] gives 1 and [2, 3], and const { id, ...rest } = record gives everything except id. Object rest follows the same copying rules as spread, so it’s shallow and drops prototypes.
When spread becomes a performance or stack problem
Spreading into a function call passes every element as a separate argument, and arguments go on the call stack. Engines cap that:
Math.max(...new Array(100_000).fill(1)); // works
Math.max(...new Array(120_000).fill(1)); // RangeError: Maximum call stack size exceededThose limits were measured in Node 22 on the machine used for this article; the threshold varies by engine and stack size. array.push(...other) has the same limit, so appending a large API response with spread can crash where a loop or concat wouldn’t. For large arrays, use reduce for the maximum and a loop or concat for appending. Also remember Math.max(...[]) is -Infinity, so check for empty input.
The second trap is spreading an accumulator inside reduce:
entries.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {});Every step copies the entire accumulator so far, which makes the whole reduce quadratic. Timed in Node 22: 1,000 entries took 66 ms, 5,000 took about 2.4 seconds, and 10,000 took about 10 seconds. Object.fromEntries(entries) did 20,000 entries in about 6 ms, and so did a reduce that assigns acc[k] = v and returns acc. Mutating an accumulator you created inside the reduce call is fine; nobody else can see it.
Interview question: why did the defaults disappear?
Question. A component builds chart options with const options = { ...DEFAULTS, ...props.options }, where DEFAULTS = { color: 'blue', legend: { show: true, position: 'top' } }. A caller passes { color: props.color, legend: { position: 'bottom' } } and reports two bugs: the colour is sometimes missing, and the legend vanished. Explain both.
Answer. When props.color isn’t provided, the caller’s object still has a color key whose value is undefined, and spread copies it over 'blue'. Spread replaces on key presence, not on whether the value is defined. The legend vanished because the merge is shallow: the caller’s legend object replaces the default one entirely, so show: true is lost and the chart library sees show as missing. Fix the first by removing undefined values before merging (or by having the caller omit the key), and the second by merging legend one level down: legend: { ...DEFAULTS.legend, ...props.options?.legend }. If options get deeper, a small deep-merge helper with explicit rules for arrays is cleaner than nesting spreads. A strong answer also mentions the reverse risk: putting the spreads in the opposite order would make every caller’s options silently ignored.