jonathanjuliani
Spread and Rest in JavaScript: arrays, objects, and the shallow copy gotchas
Spread operator and rest in JavaScript: how they work in arrays, objects, and functions, and the shallow copy gotchas that catch every developer.
You do const copy = [...original], modify the copy, and the original array changes with it. Or you clone an object with spread and discover a nested property is still a shared reference. Spread looks simpler than it is — and the bugs it hides are the hardest to debug because they throw no exception.
Spread and rest use the same syntax (...) for opposite purposes: spread expands an iterable into its elements, rest collects separate elements into an array. Understanding when each one kicks in — and where shallow copy gets you — is what this post covers.
What you'll learn:
- Spread in arrays: clone, combine, and spread as function arguments
- Spread in objects: replacing
Object.assignfor good - Rest parameters: collecting variadic arguments cleanly
- Shallow copy gotchas and how to do a deep copy when you need one
Prerequisites: arrays, objects, and basic JavaScript functions. If you want to see object spread in the context of ES2018 features — where it was introduced — ECMAScript ES7 to ES10: the features you use without knowing where they came from has the full picture of where each feature came from.
Spread in arrays: clone, combine, and spread as arguments
The ... syntax before an array expands it into its individual elements. The most common case is creating a shallow copy:
const original = [1, 2, 3];
const copy = [...original];
copy.push(4);
console.log(original); // [1, 2, 3] — unaffected
console.log(copy); // [1, 2, 3, 4]To combine arrays, spread is more readable than concat:
const fruits = ['apple', 'banana'];
const veggies = ['carrot', 'lettuce'];
const grocery = [...fruits, ...veggies];
// ['apple', 'banana', 'carrot', 'lettuce']
// you can also insert individual items in between
const everything = ['bread', ...fruits, 'milk', ...veggies];
// ['bread', 'apple', 'banana', 'milk', 'carrot', 'lettuce']Another direct use: spreading an array as function arguments. Before spread, you used .apply():
const numbers = [3, 1, 4, 1, 5, 9];
// before
Math.max.apply(null, numbers); // 9
// with spread
Math.max(...numbers); // 9 — much cleanerWorks with any function that takes multiple arguments:
function sum(a, b, c) {
return a + b + c;
}
const values = [10, 20, 30];
sum(...values); // 60Spread in objects: no more Object.assign
Object spread was introduced in ES2018 and solves what Object.assign solved, with less verbosity.
Cloning an object:
const user = { name: 'Ana', age: 28 };
const copy = { ...user };
copy.name = 'Bia';
console.log(user.name); // 'Ana' — unaffected
console.log(copy.name); // 'Bia'Merging objects — when there's a duplicate key, the last one wins:
const defaults = { theme: 'light', language: 'en-US', notifications: true };
const preferences = { theme: 'dark', fontSize: 16 };
const config = { ...defaults, ...preferences };
// { theme: 'dark', language: 'en-US', notifications: true, fontSize: 16 }Overriding specific properties without mutating the original — a very common pattern in Redux and immutable state management:
function updateUser(user, changes) {
return { ...user, ...changes }; // returns new object, doesn't mutate original
}
const updated = updateUser(user, { age: 29 });What's actually different from
Object.assign?
Object.assign mutates the first argument:
const result = Object.assign(defaults, preferences);
// defaults was mutated — result and defaults are the same objectWith spread, you always get a new object. The accidental mutation of Object.assign is why object spread became the standard. If you want to use Object.assign without mutating, you pass an empty object as the first argument: Object.assign({}, a, b) — but at that point, spread is more readable.
Rest parameters: spread in reverse
Rest parameters collect the remaining arguments of a function call into a single array. It's ... on the receiving side, not the calling side:
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3); // 6
sum(10, 20, 30, 40); // 100You can combine regular parameters with rest — but rest must come last:
function log(level, ...messages) {
// level is the first argument
// messages is an array with the rest
messages.forEach(msg => console.log(`[${level}]`, msg));
}
log('INFO', 'server started', 'port 3000');
// [INFO] server started
// [INFO] port 3000The difference between rest and the arguments object:
function withArguments() {
console.log(arguments); // Arguments object — not a real array
console.log([...arguments]); // array, but a workaround
}
function withRest(...args) {
console.log(args); // real array — has .map, .filter, everything
}arguments doesn't exist in arrow functions, isn't a real array, and doesn't work with destructuring. Rest parameters solves all of that.
When do I use rest vs. spread?
Easy way to remember: rest is in the function definition (collecting what arrives), spread is at the call site (expanding what you send). They're inverse operations:
// rest — in the signature
function join(...parts) { return parts.join('-'); }
// spread — at the call
const words = ['let', 'us', 'go'];
join(...words); // 'let-us-go'Gotchas: shallow copy, nested objects, and spread on strings
Shallow copy
Spread only copies the first level. If the array or object contains references — other objects, nested arrays — those references are shared, not cloned:
[IMAGE: diagram showing two objects with spread — primitive properties are copied by value, but object properties point to the same reference in memory | alt: "Shallow copy diagram with JavaScript spread: primitive values copied, nested objects share reference"]
const original = {
name: 'Ana',
address: { city: 'NYC', street: '5th Ave' }, // nested object
};
const copy = { ...original };
copy.name = 'Bia'; // ok — primitive, independent copy
copy.address.city = 'LA'; // DANGER — mutated the original too
console.log(original.name); // 'Ana' — ok
console.log(original.address.city); // 'LA' — oops[IMAGE: browser console screenshot showing original.address.city as 'LA' after mutation via copy | alt: "Browser console showing that nested object spread shares reference — original is mutated when modifying the copy"]
The same happens with arrays of objects:
const users = [{ name: 'Ana' }, { name: 'Bia' }];
const copy = [...users];
copy[0].name = 'Carlos';
console.log(users[0].name); // 'Carlos' — the object inside is still a shared referenceSo is spread useless for real copying?
No — spread is perfect for flat structures, which covers most cases. The problem is using spread expecting a deep copy when the structure has nesting.
When you need a deep copy, there are two main options:
structuredClone (modern, Node.js 17+ and modern browsers) — the correct way:
const copy = structuredClone(original); // real deep clone, no hacksJSON.parse(JSON.stringify(...)) (works in more environments, but has limitations):
const copy = JSON.parse(JSON.stringify(original));
// loses: undefined, functions, Date becomes string, Set/Map become {}Use structuredClone if the environment supports it. Only fall back to JSON.parse/stringify if you need broader compatibility and know the limitations.
Spread on strings
A useful one: spread works on any iterable, including strings:
const letters = [...'hello']; // ['h', 'e', 'l', 'l', 'o']
// useful for converting a string into an array of characters
const chars = [...'JavaScript'];
// ['J', 'a', 'v', 'a', 'S', 'c', 'r', 'i', 'p', 't']Unlike split(''), spread handles multi-byte Unicode characters correctly (emojis, characters from languages like Arabic and Japanese):
'😀🎉'.split(''); // ['', '', '', ''] — breaks the bytes, not the emojis
[...'😀🎉']; // ['😀', '🎉'] — correctQuick reference
| Use | Syntax | Result |
|---|---|---|
| Clone array | [...arr] | New array, first level copied |
| Combine arrays | [...a, ...b] | New array with elements from both |
| Spread into function | fn(...arr) | Passes each element as argument |
| Clone object | {...obj} | New object, first level copied |
| Merge objects | {...a, ...b} | Last one wins on duplicate key |
| Rest parameters | function f(...args) | args is array of arguments |
| Deep copy | structuredClone(obj) | Real deep clone |
| Spread on string | [...'text'] | Array of characters (Unicode correct) |
| Primitives | always copied by value | Modification to copy doesn't affect original |
| Nested objects | copied by reference | Modification affects original — use structuredClone |
Next steps
Object spread landed in ES2018 — if you want the full context of where it came from alongside Object.fromEntries, flat(), and for await...of, ECMAScript ES7 to ES10: the features you use without knowing where they came from covers all of that with practical examples.
And if you use spread to build payloads for async requests, understanding Promises for real helps close the loop: Promises in JavaScript: from zero to async/await.
[PREENCHER: add personal CTA here — something like "Which spread gotcha bit you in production? The shallow copy one gets everyone at least once. Drop it in the comments — and if you spotted something wrong here, feel free to correct it :D"]
Subscribe to the Engineering Ledger
Get architecture and performance notes in your inbox. Same list as the timed prompt—subscribe here anytime.
Related articles
Promises in JavaScript: from zero to async/await
JavaScript Promises and async/await from the ground up: states, chaining, error handling, and the gotchas official docs tend to skip.
Read story
Implementing Binary Search with NodeJS and Javascript
Binary search in JavaScript: iterative and recursive forms, sorted arrays, and O(log n) complexity. Series finale with real code.
Read story