Skip to main content

Command Palette

Search for a command to run...

Spread vs Rest Operators in JavaScript: Expanding vs Collecting

Published
6 min read
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);
}

Further reading