Skip to main content

✨ Spread and Rest Operators

Two of the most useful tools in modern JavaScript share the exact same three-dot spelling — ... — yet do opposite things. Spread expands a collection into its pieces; rest gathers loose pieces into a collection. Once you can tell which one you're looking at from context, whole categories of everyday code get shorter and safer.

šŸŽÆ Learning Objectives

By the end of this lesson, you will be able to:

  • Use the spread operator to copy and combine arrays and objects
  • Use the rest operator to collect function arguments and destructured leftovers
  • Explain how context alone decides whether ... is spread or rest
  • Recognize that spread makes a shallow copy, and update nested state immutably despite it
  • Apply these operators to real patterns: merging config, immutable updates, and flexible APIs

Estimated Time: 30–40 minutes  ā€¢  Difficulty: Intermediate

Hands-on: Merge default and user settings, then perform a Redux-style immutable update on nested state.

In This Lesson

One Syntax, Two Jobs

Spread arrived for arrays in ES6 (2015) and for objects in ES2018. Rest arrived alongside them. They are visual twins — both written ... — but they pull in opposite directions:

  • The spread operator is like tipping a box out onto a table, spreading its contents into the open.
  • The rest operator is like sweeping loose items back into a box, collecting them together.
Spread expands, rest collects The spread operator turns one array or object into individual elements. The rest operator turns individual elements back into one array or object. Spread — expands [a, b, c] ... a b c one → many Rest — collects a b c ... [a, b, c] many → one
Figure 1 — Same three dots, opposite directions. Context is the only thing that tells them apart.

The Spread Operator

Spread expands an iterable (like an array) or an object's own properties into a new place — a new array literal, a new object literal, or a function's argument list.

Spreading arrays

const numbers = [1, 2, 3];

// Combine arrays
const more = [4, 5, 6];
const combined = [...numbers, ...more]; // [1, 2, 3, 4, 5, 6]

// Insert in the middle
const inserted = [0, ...numbers, 4];    // [0, 1, 2, 3, 4]

// Copy (shallow) — the copy is independent at the top level
const copy = [...numbers];
copy.push(99);
console.log(numbers); // [1, 2, 3]  (unchanged)
console.log(copy);    // [1, 2, 3, 99]

Spreading objects

const person = { name: 'Alice', age: 30 };

// Add / override properties
const withJob = { ...person, job: 'Developer' };
// { name: 'Alice', age: 30, job: 'Developer' }

// Merge two objects
const address = { city: 'New York', country: 'USA' };
const profile = { ...person, ...address };

// Last write wins — userSettings overrides the default theme
const settings = { theme: 'light', fontSize: 14 };
const userSettings = { theme: 'dark' };
const final = { ...settings, ...userSettings };
console.log(final); // { theme: 'dark', fontSize: 14 }

Spread in function calls

const values = [5, 2, 8, 1, 4];

// Math.max wants separate arguments, not an array. Spread supplies them:
console.log(Math.max(...values)); // 8
// (The old way was Math.max.apply(null, values) — spread is far clearer.)

Shallow Copy Caveat

This is the one thing that trips people up. Spread copies only one level deep. Top-level values are duplicated, but any nested object or array is still shared by reference between the original and the copy.

A shallow copy shares nested references Two objects each have their own copied top-level name property, but both point at the very same nested address object, so editing the nested object through one affects the other. original name: "Bob" (own copy) address ─────► copy = { ...original } name: "Bob" (own copy) ◄───── address shared address one object, two arrows
Figure 2 — Both objects hold their own name, but the same address. Mutating address through either one changes both.
const user = { name: 'Bob', address: { city: 'Chicago' } };
const clone = { ...user };

clone.name = 'Robert';           // top-level: independent
console.log(user.name);          // 'Bob'  (unchanged) āœ“

clone.address.city = 'Miami';    // nested: SHARED reference
console.log(user.address.city);  // 'Miami' (changed too!) āœ—

āš ļø When you need a true deep copy

For nested data, spread alone is not enough. Use the modern built-in structuredClone(value) for a real deep copy, or spread each level explicitly when doing immutable updates (shown below). The old JSON.parse(JSON.stringify(obj)) trick works but silently drops functions, undefined, and Date precision.

The Rest Operator

Rest does the reverse: it gathers "everything that's left" into a single array or object. You see it in two places — function parameters and destructuring.

Rest in function parameters

// Collect any number of arguments into a real array
function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2));          // 3
console.log(sum(1, 2, 3, 4, 5)); // 15

// Mix fixed parameters with a trailing rest
function createTeam(name, leader, ...members) {
  return { name, leader, members, size: members.length + 1 };
}
console.log(createTeam('Eng', 'Alice', 'Bob', 'Charlie'));
// { name: 'Eng', leader: 'Alice', members: ['Bob', 'Charlie'], size: 3 }

šŸ“– Rest must come last

A rest parameter has to be the final parameter — function f(a, ...rest) is valid, but function f(...rest, a) is a syntax error. The same rule applies inside array and object destructuring.

Rest in destructuring

// Array: capture the tail
const [first, second, ...remaining] = [1, 2, 3, 4, 5];
console.log(remaining); // [3, 4, 5]

// Object: capture everything not named
const { name, age, ...otherProps } = {
  name: 'Alice', age: 30, job: 'Developer', city: 'NYC'
};
console.log(otherProps); // { job: 'Developer', city: 'NYC' }

A favorite practical use: strip one field off an object and keep the rest untouched — for example, removing a password before sending a user object to the client.

const userRecord = { id: 1, name: 'Alice', password: 'secret' };
const { password, ...safeUser } = userRecord;
console.log(safeUser); // { id: 1, name: 'Alice' } — no password

Spread vs. Rest: Context Table

Here is the reliable rule of thumb: if ... is on the right of an assignment or inside a call/literal, it is spread (expanding). If it is on the left, in a parameter list or a destructuring target, it is rest (collecting).

ContextRoleExample
Function call argumentsSpreadfunc(...array)
Function parameter listRestfunction func(...args) {}
Array literalSpread[...array, 4, 5]
Array destructuring targetRestconst [a, ...rest] = array
Object literalSpread{ ...object, prop: value }
Object destructuring targetRestconst { a, ...rest } = object
function addTags(first, second, ...otherTags) {   // ...otherTags is REST (collects)
  return ['featured', first, second, ...otherTags]; // ...otherTags is SPREAD (expands)
}
console.log(addTags('js', 'tutorial', 'es6', 'web'));
// ['featured', 'js', 'tutorial', 'es6', 'web']

Real-World Patterns

Immutable state updates (React / Redux style)

Modern UI frameworks expect you to produce a new state object rather than mutating the old one. Spread at each level is the standard tool — note how you spread every level along the path you're changing:

const state = {
  user: { id: 42, name: 'Alice', preferences: { theme: 'light', fontSize: 14 } },
  posts: [{ id: 1, likes: 5 }, { id: 2, likes: 10 }]
};

// Change a deeply nested value without touching the original
const nextState = {
  ...state,
  user: {
    ...state.user,
    preferences: { ...state.user.preferences, theme: 'dark' }
  }
};
console.log(nextState.user.preferences.theme); // 'dark'
console.log(state.user.preferences.theme);     // 'light' (unchanged) āœ“

// Update one item in an array immutably with map
const withLike = {
  ...state,
  posts: state.posts.map(p => p.id === 2 ? { ...p, likes: p.likes + 1 } : p)
};
console.log(withLike.posts[1].likes); // 11
console.log(state.posts[1].likes);    // 10 (unchanged) āœ“

Merging configuration with overrides

function apiRequest(url, data, options = {}) {
  const defaults = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }
  };
  return fetch(url, {
    ...defaults,
    ...options,
    // Merge headers deliberately so caller headers add to (not erase) the defaults
    headers: { ...defaults.headers, ...options.headers },
    body: JSON.stringify(data)
  });
}

Function composition

const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

const double = n => n * 2;
const increment = n => n + 1;
const square = n => n * n;

const run = pipe(double, increment, square); // left to right
console.log(run(5)); // ((5*2)+1)^2 = 121

āš ļø Don't spread inside a hot loop

Rebuilding an array with spread on every iteration is O(n²): result = [...result, i] copies the whole array each time. For accumulation, plain result.push(i) is dramatically faster. Reserve spread for creating a copy once, not repeatedly.

Hands-on Exercise

šŸ‹ļø Settings Merger & Immutable Update

Objective: Practice spread for merging and rest for collecting, then perform a safe nested update.

Part A — Merge with a forced field

Write mergeSettings(defaults, overrides) that returns a new object combining both (overrides win), but always stamps a fresh updatedAt of new Date().toISOString() regardless of what either argument says.

Part B — Immutable nested update

Given the state below, produce nextState where settings.notifications.email becomes false, without mutating state. Confirm the original still reads true.

const state = {
  username: 'ray',
  settings: {
    theme: 'dark',
    notifications: { email: true, sms: false }
  }
};
šŸ’” Hint

For Part A, spread defaults then overrides, and put updatedAt after both so it can't be overridden. For Part B, spread each level down the path: state → settings → notifications, changing only email at the bottom.

āœ… Solution
// Part A
function mergeSettings(defaults, overrides) {
  return {
    ...defaults,
    ...overrides,
    updatedAt: new Date().toISOString() // last write wins — always fresh
  };
}

// Part B
const nextState = {
  ...state,
  settings: {
    ...state.settings,
    notifications: { ...state.settings.notifications, email: false }
  }
};

console.log(nextState.settings.notifications.email); // false
console.log(state.settings.notifications.email);     // true (unchanged) āœ“

Quiz

šŸŽÆ Check Your Understanding

Question 1: In const [a, ...rest] = [1, 2, 3];, is the ... acting as spread or rest, and what is rest?

Question 2: After const copy = { ...user }, you run copy.address.city = 'Miami'. What happens to user.address.city?

Question 3: Which call correctly passes the array nums as separate arguments to Math.max?

Summary & What's Next

šŸŽ‰ Key Takeaways

  • Spread expands a collection (right side, literals, call arguments); rest collects into one (left side, parameter lists, destructuring targets).
  • Spread makes a shallow copy — nested objects and arrays are still shared; use structuredClone or spread each level for deep changes.
  • Immutable updates spread every level along the path you're changing.
  • A rest element must be last.
  • Avoid repeated spread inside loops — it's quietly O(n²).

šŸ“š Further Reading

šŸš€ What's Next?

You've now got the modern toolkit for reshaping data. Next we dig into one of JavaScript's most powerful — and most interview-asked — ideas: closures, and how functions remember the variables around them.

šŸŽ‰ Excellent!

Spread and rest will show up in almost every file you write from here on. Next stop: understanding closures.