JavaScript Spread and Rest Operators Explained — Real-World Patterns and Pitfalls
Every modern JavaScript codebase is full of three little dots — ... — and for good reason. Whether you're merging objects in a Redux reducer, passing dynamic arguments to a function, or cloning arrays without mutating the original, the spread and rest operators are doing the heavy lifting. They're not just syntactic sugar; they fundamentally change how you think about data flow in JavaScript.
Before ES6 introduced these operators, copying arrays meant using .slice(), merging objects required Object.assign(), and handling variable-length function arguments meant wrestling with the awkward arguments object. That code was verbose, error-prone, and frankly hard to read at a glance. The ... syntax replaced all of that with something readable enough that your intent is obvious the moment someone looks at your code.
By the end of this article you'll know the difference between spread and rest at a conceptual level (not just syntactically), you'll recognise the real-world patterns where each one shines, you'll understand the gotchas that trip up even experienced developers, and you'll be ready to answer the interview questions that separate candidates who 'know the syntax' from those who actually understand JavaScript.
Spread Operator: Unpacking Collections Where You Need Individual Items
The spread operator takes an iterable — an array, a string, a Set, or any object with a Symbol.iterator — and expands it in-place. Think of it as opening a bag and tipping everything out onto the table.
The most important word there is 'in-place'. Spread doesn't return anything on its own; it works by expanding values into the surrounding context. That context might be an array literal, a function call, or an object literal — and each context behaves slightly differently.
When you spread inside an array literal [...a, ...b], you're building a new array from the elements of both. This is a shallow copy — primitives are copied by value, but nested objects are still referenced. When you spread inside a function call Math.max(...scores), you're passing each element as a separate argument. When you spread inside an object literal { ...defaults, ...overrides }, later keys win, which makes it perfect for configuration merging.
The key insight: spread is about placement. You're deciding exactly where the unpacked values land, and that precision is what makes it so powerful for immutable update patterns.
// ─── 1. Combining arrays without mutating either original ─────────────────── const morningTasks = ['email', 'standup', 'code review']; const afternoonTasks = ['pair programming', 'deploy', 'retrospective']; // spread both arrays into a new array — neither original is touched const fullDaySchedule = [...morningTasks, 'lunch', ...afternoonTasks]; console.log(fullDaySchedule); // ['email', 'standup', 'code review', 'lunch', 'pair programming', 'deploy', 'retrospective'] // ─── 2. Passing array values as individual function arguments ──────────────── const temperatures = [22, 19, 31, 28, 17, 25]; // Math.max expects individual numbers, not an array — spread unpacks them const hottest = Math.max(...temperatures); console.log(`Hottest day: ${hottest}°C`); // Hottest day: 31°C // ─── 3. Shallow-cloning an array (avoids accidental mutation) ──────────────── const originalCart = [{ id: 1, name: 'Keyboard' }, { id: 2, name: 'Mouse' }]; const cartCopy = [...originalCart]; // Adding to the copy does NOT affect the original cartCopy.push({ id: 3, name: 'Monitor' }); console.log(originalCart.length); // 2 — original is safe console.log(cartCopy.length); // 3 // ─── 4. Merging config objects — later keys override earlier ones ──────────── const defaultSettings = { theme: 'light', fontSize: 14, notifications: true }; const userSettings = { theme: 'dark', fontSize: 16 }; // userSettings properties overwrite matching keys from defaultSettings const finalSettings = { ...defaultSettings, ...userSettings }; console.log(finalSettings); // { theme: 'dark', fontSize: 16, notifications: true } // ─── 5. Spreading a string into individual characters ─────────────────────── const greeting = 'hello'; const letters = [...greeting]; console.log(letters); // ['h', 'e', 'l', 'l', 'o']
Hottest day: 31°C
2
3
{ theme: 'dark', fontSize: 16, notifications: true }
['h', 'e', 'l', 'l', 'o']
Rest Parameters: Capturing 'Everything Else' Into One Clean Collection
Rest is the mirror image of spread. Where spread expands, rest collects. Its job is to gather up a variable number of values and bundle them into a real JavaScript array that you can then work with normally.
The critical word is 'real array'. Before rest parameters existed, functions used the arguments object to access extra arguments. arguments looks like an array but isn't — it has no .map(), no .filter(), no .reduce(). Developers constantly had to convert it: Array.from(arguments) or [].slice.call(arguments). Rest parameters made that hack obsolete.
Rest must always be the last parameter in a function signature, and there can only be one. This makes logical sense — you can't say 'collect everything else' twice in the same list. Importantly, rest only collects arguments that don't have an explicit parameter waiting for them. Named parameters come first, then rest catches whatever remains.
Beyond function signatures, rest also appears in destructuring. You can destructure the first few elements of an array and then use rest to grab the tail. Same idea, same symbol — pull out what you need, bag up the rest.
// ─── 1. Basic rest parameter: collect unlimited arguments ──────────────────── function calculateOrderTotal(discountPercent, ...itemPrices) { // itemPrices is a genuine array — .reduce works directly on it const subtotal = itemPrices.reduce((sum, price) => sum + price, 0); const discount = subtotal * (discountPercent / 100); return (subtotal - discount).toFixed(2); } console.log(calculateOrderTotal(10, 29.99, 14.99, 49.99)); // First arg (10) goes to discountPercent // Remaining args go into itemPrices: [29.99, 14.99, 49.99] // Output: '84.57' console.log(calculateOrderTotal(0, 9.99)); // itemPrices: [9.99] — rest works fine with just one item // Output: '9.99' // ─── 2. Logging utility — classic real-world use case ──────────────────────── function logWithTimestamp(level, ...messages) { const timestamp = new Date().toISOString(); // Join all messages with a space, prepend level tag and timestamp console.log(`[${timestamp}] [${level.toUpperCase()}]`, ...messages); } logWithTimestamp('info', 'Server started on port', 3000); // [2024-01-15T10:30:00.000Z] [INFO] Server started on port 3000 logWithTimestamp('error', 'Database connection failed:', 'ECONNREFUSED'); // [2024-01-15T10:30:01.000Z] [ERROR] Database connection failed: ECONNREFUSED // ─── 3. Rest in array destructuring — grab head, bag the tail ──────────────── const [currentUser, secondInQueue, ...waitingList] = [ 'Alice', 'Bob', 'Carol', 'Dave', 'Eve' ]; console.log(currentUser); // 'Alice' console.log(secondInQueue); // 'Bob' console.log(waitingList); // ['Carol', 'Dave', 'Eve'] // ─── 4. Rest in object destructuring — extract known keys, collect the rest ── const { id, createdAt, ...publicProfile } = { id: 'usr_abc123', createdAt: '2023-06-01', username: 'jsmith', bio: 'Software engineer', avatar: 'https://example.com/avatar.jpg' }; // publicProfile contains everything except id and createdAt // Great for stripping sensitive fields before sending data to the client console.log(publicProfile); // { username: 'jsmith', bio: 'Software engineer', avatar: 'https://example.com/avatar.jpg' }
'9.99'
[2024-01-15T10:30:00.000Z] [INFO] Server started on port 3000
[2024-01-15T10:30:01.000Z] [ERROR] Database connection failed: ECONNREFUSED
'Alice'
'Bob'
['Carol', 'Dave', 'Eve']
{ username: 'jsmith', bio: 'Software engineer', avatar: 'https://example.com/avatar.jpg' }
When Spread Meets Real-World React and Functional Patterns
Knowing the syntax is one thing. Knowing where spread and rest earn their keep in production code is what separates developers who 'know ES6' from those who write genuinely maintainable JavaScript.
In React, spread is everywhere. Passing all props from a parent to a child without listing every one individually — — is idiomatic React. Updating nested state immutably inside a reducer — { ...state, cart: [...state.cart, newItem] } — is how Redux and useReducer expect you to work. Mutable updates cause bugs that are nearly impossible to trace; spread makes immutability natural and readable.
In functional programming patterns, rest parameters make higher-order functions far more composable. A wrapper function that needs to forward all its arguments to an inner function can capture them with rest and forward them with spread — a pattern sometimes called 'argument forwarding'. This is how libraries like Express build middleware chains without knowing in advance how many arguments a handler might need.
The deeper principle: spread and rest let you work with data as flows rather than fixed structures. You stop thinking about arrays and objects as containers with a set shape and start thinking about them as sequences and collections you can freely transform.
// ─── 1. Immutable state update pattern (mirrors React useReducer / Redux) ───── const initialCartState = { items: [ { id: 1, name: 'Headphones', qty: 1 } ], couponApplied: false, totalItems: 1 }; function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': return { ...state, // keep all existing state items: [...state.items, action.payload], // add new item without mutating totalItems: state.totalItems + 1 }; case 'APPLY_COUPON': return { ...state, couponApplied: true }; // only this key changes default: return state; } } const stateAfterAdd = cartReducer(initialCartState, { type: 'ADD_ITEM', payload: { id: 2, name: 'Webcam', qty: 1 } }); console.log(stateAfterAdd.items.length); // 2 — original is untouched console.log(initialCartState.items.length); // 1 — confirmed // ─── 2. Argument forwarding — wrapping a function without knowing its signature ─ function withPerformanceLogging(wrappedFn) { // Rest captures ALL arguments that get passed to the returned function return function (...args) { const start = performance.now(); // Spread forwards ALL those arguments to the original function const result = wrappedFn(...args); const duration = (performance.now() - start).toFixed(3); console.log(`${wrappedFn.name} took ${duration}ms`); return result; }; } function computeShippingCost(weightKg, distanceKm, expedited) { const base = weightKg * 0.5 + distanceKm * 0.02; return expedited ? base * 1.8 : base; } const timedShippingCost = withPerformanceLogging(computeShippingCost); const cost = timedShippingCost(2.5, 300, true); // computeShippingCost took 0.012ms console.log(`Shipping: $${cost.toFixed(2)}`); // Shipping: $13.05 // ─── 3. Building flexible API query helpers ────────────────────────────────── const defaultQueryOptions = { method: 'GET', headers: { 'Content-Type': 'application/json' }, cache: 'default', credentials: 'same-origin' }; function buildRequest(endpoint, ...optionOverrides) { // Merge all override objects left-to-right — last key wins const mergedOptions = Object.assign({}, defaultQueryOptions, ...optionOverrides); return { url: `https://api.example.com${endpoint}`, ...mergedOptions }; } const authRequest = buildRequest( '/users/profile', { credentials: 'include' }, { headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token123' } } ); console.log(authRequest.credentials); // 'include' console.log(authRequest.headers['Authorization']); // 'Bearer token123' console.log(authRequest.cache); // 'default' — inherited from defaults
1
computeShippingCost took 0.012ms
Shipping: $13.05
'include'
'Bearer token123'
'default'
Spread vs Rest Side-by-Side: Knowing Which One You're Looking At
The biggest source of confusion is that both operators use identical syntax — ... — and yet they do opposite things. The rule is simple once you internalise it: position in the code tells you which role the dots are playing.
If ... appears on the right-hand side of an assignment, inside a function call's argument list, or inside an array/object literal, it's spread — it's expanding something outward. If ... appears in a function parameter list or on the left-hand side of a destructuring assignment, it's rest — it's collecting things inward.
Another useful mental model: spread produces multiple values from one, rest produces one collection from multiple. Spread is a 1-to-many operation. Rest is a many-to-1 operation.
The table below captures the key practical differences at a glance. When you're reading someone else's code and see ..., scan left — if you're inside a function signature or a destructuring pattern on the left of =, it's rest. Everything else is almost certainly spread.
// ─── SPREAD — always on the 'output' side ──────────────────────────────────── // In a function CALL (spreading args into a function) const ratings = [4, 2, 5, 3, 5, 1]; const highestRating = Math.max(...ratings); // SPREAD — right side of call console.log(highestRating); // 5 // In an array LITERAL (spreading elements into a new array) const firstHalf = ['Mon', 'Tue', 'Wed']; const secondHalf = ['Thu', 'Fri']; const workWeek = [...firstHalf, ...secondHalf]; // SPREAD — inside array literal console.log(workWeek); // ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] // In an object LITERAL (spreading properties into a new object) const baseTheme = { primaryColor: '#007bff', borderRadius: '4px' }; const darkOverlay = { primaryColor: '#1a1a2e', background: '#16213e' }; const darkTheme = { ...baseTheme, ...darkOverlay }; // SPREAD — inside object literal console.log(darkTheme); // { primaryColor: '#1a1a2e', borderRadius: '4px', background: '#16213e' } // ─── REST — always on the 'input' side ─────────────────────────────────────── // In a function PARAMETER list (collecting extra args into an array) function sendNotification(userId, ...channels) { // REST — in function signature channels.forEach(channel => console.log(`Notifying user ${userId} via ${channel}`) ); } sendNotification('usr_99', 'email', 'sms', 'push'); // Notifying user usr_99 via email // Notifying user usr_99 via sms // Notifying user usr_99 via push // In DESTRUCTURING (collecting remaining items after named ones) const [admin, moderator, ...regularUsers] = // REST — left side of destructuring ['alice@x.com', 'bob@x.com', 'carol@x.com', 'dave@x.com']; console.log(admin); // 'alice@x.com' console.log(moderator); // 'bob@x.com' console.log(regularUsers); // ['carol@x.com', 'dave@x.com']
['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
{ primaryColor: '#1a1a2e', borderRadius: '4px', background: '#16213e' }
Notifying user usr_99 via email
Notifying user usr_99 via sms
Notifying user usr_99 via push
'alice@x.com'
'bob@x.com'
['carol@x.com', 'dave@x.com']
| Feature / Aspect | Spread (...) | Rest (...) |
|---|---|---|
| What it does | Expands one iterable into many individual values | Collects many individual values into one array |
| Position in code | Right side of assignment, inside literals, in function calls | Left side of destructuring, in function parameter lists |
| Input type | Any iterable (array, string, Set, Map, object) | Individual values / arguments |
| Output type | Individual values placed into surrounding context | A true JavaScript Array |
| Can appear multiple times | Yes — spread multiple sources in one expression | No — only one rest element per function/destructuring |
| Must be last? | No — spread can appear anywhere in the list | Yes — rest must always be the last parameter |
| Works with objects? | Yes — ES2018+ object spread (own enumerable props only) | Yes — object destructuring rest (same rules) |
| Common real-world use | Merging state, cloning arrays, passing args to functions | Variadic functions, stripping known keys from objects |
| Replaces old approach | Array.prototype.concat(), Object.assign(), .apply() | The arguments object (which is array-like, not a real array) |
🎯 Key Takeaways
- Same syntax
..., opposite roles: spread expands one thing into many; rest collects many things into one — position in code tells you which is which. - Spread creates shallow copies only — nested objects are still shared references, which causes silent mutation bugs if you're not expecting it.
- Rest parameters produce a real JavaScript Array (unlike the old
argumentsobject), so you can chain.map(),.filter(), and.reduce()directly on them without any conversion. - In React and Redux, object/array spread is the idiomatic way to do immutable state updates — new references are what trigger re-renders, and spread creates them naturally.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Spreading an object into an array — Symptom: TypeError: object is not iterable — Fix: Object spread only works inside object literals
{...obj}. Objects aren't iterable by default, so[...myObject]throws. UseObject.entries(myObject)if you need an array of key-value pairs, orObject.values(myObject)for just the values. - ✕Mistake 2: Expecting spread to deep-clone nested objects — Symptom: Mutating a nested object in the 'copy' silently mutates the original too — Fix:
const copy = { ...original }only copies top-level properties by value. Nested objects are still shared references. For a true deep clone usestructuredClone(original)in modern environments, orJSON.parse(JSON.stringify(original))for plain serialisable data. - ✕Mistake 3: Placing rest parameter before other parameters — Symptom: SyntaxError: Rest parameter must be last formal parameter — Fix: Rest must always be the final parameter in a function signature.
function process(...items, callback)is illegal. Reorder tofunction process(callback, ...items)or use destructuring inside the body. This rule exists because JavaScript needs to know where 'named parameters' end and 'everything else' begins.
Interview Questions on This Topic
- QWhat's the difference between the spread operator and the rest parameter? Can you give an example where the same `...` syntax means different things in the same file?
- QIf you spread an array of objects to clone it, is the clone safe to mutate freely? Walk me through what actually happens in memory.
- QYou're writing a higher-order function that wraps any other function — you don't know how many arguments that function takes. How do you forward all arguments correctly, and which operators make that possible?
Frequently Asked Questions
Can I use the spread operator to deep clone an object in JavaScript?
No — spread only performs a shallow clone. Top-level primitive values are copied correctly, but nested objects remain as shared references between the original and the copy. To deep clone, use structuredClone(obj) in Node 17+ and modern browsers, or JSON.parse(JSON.stringify(obj)) for simple serialisable data structures.
What's the difference between the rest parameter and the `arguments` object?
Rest parameters produce a genuine JavaScript Array, so you can use all array methods (.map(), .filter(), .reduce()) directly. The arguments object is array-like but not an actual Array — calling .map() on it throws a TypeError. Rest parameters also only collect arguments that weren't matched by named parameters, whereas arguments captures everything. Rest is also not available inside arrow functions; arguments isn't either in arrow functions, but it is in regular functions.
Why does the order of objects matter when using spread to merge them?
When two spread objects share the same key, the one that appears later in the expression wins. So in { ...defaults, ...userSettings }, any key present in both objects will take its final value from userSettings. This is intentional — it mirrors how CSS specificity works and makes it natural to apply overrides. Just remember to always put your 'base' or 'default' object first and your 'override' object last.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.