Spread vs Rest Operators in JavaScript: Expanding vs Collecting

Audience: This post assumes basic familiarity with JavaScript arrays, objects, and functions.
TL;DR: Both spread (...) and rest (...) use the same three-dot syntax — but they do the opposite thing depending on context. Spread expands an iterable into individual elements. Rest collects individual elements into a single array. Knowing when you're doing which eliminates an entire class of confusion.
Problem
JavaScript's ... syntax is one of the most commonly misread features in the language. Beginners (and sometimes experienced devs) write ...args in a function call and ...args in a function signature, expecting the same behavior and get confused when the results differ.
They're not the same. Same punctuation, opposite direction of data flow.
The Core Mental Model
Think of data flow direction:
Spread → one thing becomes many (
[1, 2, 3]→1, 2, 3)Rest → many things become one (
1, 2, 3→[1, 2, 3])
The position in code tells you which one you're dealing with:
| Context | Behavior | Name |
|---|---|---|
| Function call | Expands iterable into arguments | Spread |
| Array/object literal | Expands iterable into elements | Spread |
| Function definition | Collects remaining args into array | Rest |
| Destructuring assignment | Collects remaining items | Rest |
Solution
Spread operator
Spread takes something iterable — an array, a string, a Set — and unpacks its contents in-place.
With arrays:
const first = [1, 2, 3];
const second = [4, 5, 6];
const combined = [...first, ...second];
console.log(combined); // [1, 2, 3, 4, 5, 6]
// Equivalent to:
// [].concat(first, second)
// But cleaner and works with any iterable
With objects:
Spread copies all enumerable own properties from one object into another. Later keys win — this is how you do "merge with override."
const defaults = { theme: "light", language: "en", timeout: 3000 };
const userPrefs = { theme: "dark", language: "fr" };
const config = { ...defaults, ...userPrefs };
console.log(config);
// { theme: "dark", language: "fr", timeout: 3000 }
// userPrefs.theme overrides defaults.theme
In function calls:
const numbers = [3, 1, 4, 1, 5, 9];
// Math.max doesn't accept arrays — only individual numbers
console.log(Math.max(...numbers)); // 9
// Without spread you'd need: Math.max.apply(null, numbers)
Important: Spread creates a shallow copy, not a deep one.
const original = { user: { name: "Alice" } };
const copy = { ...original };
copy.user.name = "Bob";
console.log(original.user.name); // "Bob" — same nested object reference
Rest operator
Rest collects multiple values into a single array. It only appears in two places: function parameter lists and destructuring assignments.
In function parameters:
function logMessages(level, ...messages) {
// level: string — the first argument
// messages: array — all remaining arguments
messages.forEach(msg => console.log(`[\({level}] \){msg}`));
}
logMessages("INFO", "Server started", "Listening on port 3000");
// [INFO] Server started
// [INFO] Listening on port 3000
Rest must always be the last parameter. This throws:
// SyntaxError: Rest parameter must be last formal parameter
function broken(...items, last) {}
In array destructuring:
const [head, ...tail] = [10, 20, 30, 40];
console.log(head); // 10
console.log(tail); // [20, 30, 40]
In object destructuring:
const { id, createdAt, ...publicFields } = {
id: 42,
createdAt: "2024-01-01",
name: "Alice",
role: "admin",
};
console.log(publicFields); // { name: "Alice", role: "admin" }
// Useful for stripping internal fields before sending to a client
Practical use cases
1. Immutable array updates (React state)
function addItem(cart, newItem) {
return [...cart, newItem]; // doesn't mutate cart
}
function removeItem(cart, id) {
return cart.filter(item => item.id !== id);
}
function updateItem(cart, updatedItem) {
return cart.map(item =>
item.id === updatedItem.id ? { ...item, ...updatedItem } : item
);
}
2. Merging config with defaults
function createServer(userConfig = {}) {
const defaults = {
port: 3000,
host: "localhost",
timeout: 5000,
cors: false,
};
const config = { ...defaults, ...userConfig };
return config;
}
const server = createServer({ port: 8080, cors: true });
console.log(server);
// { port: 8080, host: "localhost", timeout: 5000, cors: true }
3. Variadic utility functions
function sum(...numbers) {
return numbers.reduce((acc, n) => acc + n, 0);
}
console.log(sum(1, 2, 3)); // 6
console.log(sum(10, 20, 30, 40)); // 100
4. Cloning and extending objects
const baseButton = {
padding: "8px 16px",
borderRadius: "4px",
cursor: "pointer",
};
const primaryButton = {
...baseButton,
backgroundColor: "blue",
color: "white",
};
const dangerButton = {
...baseButton,
backgroundColor: "red",
color: "white",
};
5. Converting non-arrays to arrays
function highlightAll(selector) {
const nodes = document.querySelectorAll(selector); // NodeList, not Array
const elements = [...nodes]; // now a real Array with .map, .filter, etc.
elements.forEach(el => el.classList.add("highlight"));
}
Results
Using spread and rest correctly eliminates several common patterns that are either verbose or error-prone:
| Old pattern | Modern equivalent |
|---|---|
[].concat(a, b) |
[...a, ...b] |
Object.assign({}, defaults, overrides) |
{ ...defaults, ...overrides } |
Math.max.apply(null, arr) |
Math.max(...arr) |
Array.from(nodeList) |
[...nodeList] |
arguments object |
...args rest parameter |
The arguments object was particularly painful — it's array-like but lacks .map(), .filter(), and friends. Rest parameters give you a real array from the start.
Trade-offs
Spread is shallow. It copies property references, not values. For deeply nested structures you still need structuredClone() or a library like lodash.cloneDeep.
const a = { nested: { value: 1 } };
const b = { ...a };
b.nested.value = 99;
console.log(a.nested.value); // 99 — a was mutated
Object spread order matters. When the same key appears in multiple sources, the last one wins. A wrong merge order silently discards data.
// userConfig overrides everything — intended
const config = { ...defaults, ...userConfig };
// defaults override userConfig — almost certainly a bug
const config = { ...userConfig, ...defaults };
Spread on large arrays has memory cost. Spreading a 100,000-element array into a function call creates a new 100,000-argument invocation on the call stack. For large datasets, prefer explicit iteration.
Rest collects by position, not by name. In destructuring, ...rest captures everything after the named bindings — positional for arrays, by key exclusion for objects.
Conclusion
The ... syntax encodes direction: outward when spreading, inward when resting. Read the context — a definition or destructuring means rest, a literal or call means spread.
The practical payoff is cleaner, more composable code: immutable array/object transforms without concat or Object.assign, variadic functions without the arguments hack, and selective destructuring without manual key exclusion.
Next step: combine them. A function that both uses rest in its signature and spread in its body is idiomatic JavaScript:
function merge(...objects) {
return Object.assign({}, ...objects);
}



