FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·057·10 MIN READ

057: Destructuring, Rest, Spread, and Copy Semantics

TOPICS COVERED: Destructuring, Rest, Spread, and Copy Semantics

Learning outcomes

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

  • destructure arrays and objects, rename bindings, and provide defaults;
  • tell the difference between collecting with rest and expanding with spread;
  • copy and merge arrays and objects without changing their outer containers;
  • predict which value wins when object spreads contain the same key;
  • explain why spread produces a shallow copy rather than a deep copy.

Retrieval warm-up

Before getting into the syntax, retrieve three ideas from earlier lessons:

  1. How do you read a key held in const field = "price"?
  2. What does const alias = product copy?
  3. Does toSorted() copy each product object?

Answers: product[field], an object reference, and no. That last answer is the one to keep in mind throughout this lesson: creating a new outer container does not automatically create new objects nested inside it.

Vocabulary

  • Destructuring: Unpacking array items or object properties into distinct bindings. — Source: MDN: Destructuring assignment
  • Binding: A name created by a destructuring pattern in a declaration or parameter. — Source: MDN: Destructuring assignment
  • Default value: A = fallback used when an unpacked slot is undefined. — Source: MDN: Destructuring assignment
  • Rename: : newName syntax that binds a property under a different local name. — Source: MDN: Destructuring assignment
  • Rest: ... syntax that gathers remaining elements or properties into one target. — Source: MDN: Rest parameters
  • Spread: ... syntax that expands iterable values or properties into a receiving array, object, or call. — Source: MDN: Spread syntax
  • Shallow copy: A new outer container whose nested values still share references with the source, as in {...obj}. — Source: MDN: Spread syntax
  • Merge: Combining values with later spread sources overriding earlier keys. — Source: MDN: Spread syntax
  • Destructuring (official): "Destructuring assignment unpacks values from arrays or properties from objects into distinct variables." — Source: MDN: Destructuring assignment
  • Spread syntax (official): "Spread (...) allows an iterable to be expanded in places where zero or more arguments or elements are expected." — Source: MDN: Spread syntax

Beginner mental model

The same ... punctuation does two different jobs. Its position tells you which job it is doing:

  • On the receiving, left side of an assignment, rest collects: const [first, ...others] = values.
  • Inside a new array, object, or call, spread expands: const copy = [...values].

Destructuring is a compact way to perform property reads or position-based reads. The following two patterns make that distinction concrete:

js
const product = { id: "p1", name: "Notebook", price: 4 };
const { name, price } = product;
// Equivalent reads: const name = product.name; const price = product.price;

const coordinates = [120, 80];
const [x, y] = coordinates;

Object destructuring matches property keys, so the order of the pattern does not matter. Array destructuring follows iteration order, so position matters. To rename a binding, use property: localName:

js
const { name: productName } = product;

This does not create a local variable called name. It reads the name property and creates the local binding productName.

Defaults apply only when the value is undefined, including when the property is missing. They do not replace null, 0, false, or "":

js
const { stock = 0 } = { stock: undefined }; // 0
const { rating = 5 } = { rating: null };    // null

That distinction matters when 0, false, or an empty string is a valid value. A default is not a general truthiness fallback.

Worked beginner example: prepare a catalog card

Here, destructuring selects the fields needed for a card, collects the remaining product data, and then uses array rest and spread for tags:

js
const product = {
  id: "p3",
  name: "Water Bottle",
  price: 16,
  stock: 7,
  category: "travel",
  details: {
    color: "blue",
    dimensions: { height: 24, width: 7 },
  },
};

const {
  id,
  name: productName,
  price,
  rating = "Not rated",
  ...catalogData
} = product;

const card = {
  id,
  title: productName,
  displayPrice: `$${price}`,
  rating,
};

const tags = ["reusable", "popular"];
const allTags = ["travel", ...tags, "bpa-free"];
const [primaryTag, ...secondaryTags] = allTags;

console.log(card);
console.log(catalogData.stock);
console.log(primaryTag);
console.log(secondaryTags);
console.log(product.name);

Output:

text
{ id: "p3", title: "Water Bottle", displayPrice: "$16", rating: "Not rated" }
7
travel
["reusable", "popular", "bpa-free"]
Water Bottle

catalogData is a new object containing the enumerable own properties that were not selected: stock, category, and details. The source product is unchanged. Likewise, secondaryTags is a new array. Destructuring reads or collects values; it does not delete properties from the source.

Destructured parameters are useful when a function needs a small, known set of fields:

js
function formatProduct({ name, price, stock = 0 }) {
  return `${name} - $${price} (${stock} available)`;
}

console.log(formatProduct(product));
// Water Bottle - $16 (7 available)

There are two separate defaults to distinguish when an object parameter is optional. The property default handles a missing limit, while the object default handles a missing argument altogether: function readOptions({ limit = 10 } = {}). Without = {}, calling readOptions() attempts to destructure undefined and throws.

Copying and merging precisely

Array spread creates a new outer array. Mutating that new array does not change the original array itself:

js
const originalTags = ["new", "travel"];
const copiedTags = [...originalTags];
copiedTags.push("sale");
console.log(originalTags); // ["new", "travel"]

Object spread creates a new plain object from the source's enumerable own properties. When several definitions use the same key, the definition evaluated later wins:

js
const defaults = { currency: "USD", taxRate: 0.1, region: "global" };
const storeSettings = { taxRate: 0.18, region: "IN" };

const settings = { ...defaults, ...storeSettings, currency: "INR" };
console.log(settings);
// { currency: "INR", taxRate: 0.18, region: "IN" }

Read this expression from left to right. storeSettings.taxRate overwrites the default, as does storeSettings.region. The final explicit currency property is written last, so it overrides both earlier sources.

The shallow-copy boundary

Spread copies the outer property list. It does not recursively copy nested objects:

js
const copy = { ...product };
console.log(copy === product);                 // false
console.log(copy.details === product.details); // true

copy.details.color = "green";
console.log(product.details.color); // green

The root objects are different, but both roots point to the same details object. That shared reference is why changing copy.details.color also changes the value observed through product.

To update one nested level without changing the original, copy every container along the path to the field being changed:

js
const greenProduct = {
  ...product,
  details: {
    ...product.details,
    color: "green",
  },
};

Now greenProduct.details !== product.details. The still-deeper dimensions object remains shared because this update did not replace it. Lesson 047 develops this path-copying pattern further.

Spread is not a universal cloning mechanism. In this curriculum, do not use JSON serialization as a supposed "deep clone": it loses or changes values that JSON cannot represent. Copy only the levels required by the update. structuredClone() is available for supported data when a genuinely deep clone is required, but it does not replace understanding who owns each piece of data.

Deep copy choices and JSON limitations

Interviews and code reviews often use the word "copy" for three different operations: sharing the original reference, making a shallow copy, and making a deep copy. Those choices have different ownership and performance consequences. Spread and Object.assign() copy only the first level. For an immutable update, path copying is usually the clearest choice because it documents the changed branch while preserving structural sharing everywhere else.

structuredClone(value) recursively clones many built-in data types and preserves cycles, but it has boundaries. Functions and DOM nodes are not cloneable, and you should not assume that prototype-based class instances retain their behavior. It also creates new identities throughout the cloned graph, which can do more work than a targeted update requires.

JSON round-tripping is not a general deep-clone algorithm:

js
const source = {
  date: new Date("2024-01-01T00:00:00Z"),
  missing: undefined,
  amount: 12n,
};

// JSON.stringify(source) throws because BigInt cannot be serialized.
// Without amount, undefined object properties disappear and date becomes text.

JSON also cannot faithfully represent Map, Set, functions, symbols, NaN, or infinity. undefined values in arrays become null, and circular data causes serialization to throw. Use JSON for a deliberately JSON-shaped wire format, not as a way to clone arbitrary application state. Choose path copying for a known update, and choose structuredClone when a supported value genuinely needs independent recursive ownership.

Interview follow-ups

  • What does { ...source } guarantee? A new outer object, not independent nested values.
  • When is structuredClone inappropriate? When the graph contains unsupported values or behavior must remain attached to class instances.
  • Why not always deep clone? It discards useful identity sharing and can be expensive.
  • What should be tested? Both displayed values and identity at each relevant level.

Intermediate example: update product options

This update function allows ordinary product fields to be changed while deliberately protecting the product ID:

js
function updateProduct(product, changes) {
  return {
    ...product,
    ...changes,
    id: product.id,
  };
}

const discounted = updateProduct(product, {
  price: 14,
  badge: "Weekend deal",
  id: "attempted-change",
});

console.log(discounted.price); // 14
console.log(discounted.badge); // Weekend deal
console.log(discounted.id);    // p3
console.log(product.price);    // 16

The order of the spreads is the rule: callers can change ordinary fields, but the original ID is written last and therefore cannot be overridden by changes. Do not blindly spread external data into security-sensitive or unrestricted records. Validate and allow-list fields before merging external input.

Array and object destructuring also fit naturally into callbacks:

js
const cartEntries = [["p1", 2], ["p3", 1]];
const labels = cartEntries.map(
  ([productId, quantity]) => `${productId}: ${quantity}`,
);

Each callback argument is an entry array. The parameter pattern immediately binds its two positions to productId and quantity, which keeps the formatting logic focused on the values it actually uses.

Optional advanced extension

Object rest is useful when the requirement is to omit a known property while retaining the rest of a record:

js
function withoutInternalNote(product) {
  const { internalNote, ...publicProduct } = product;
  return publicProduct;
}

const safe = withoutInternalNote({
  id: "p1",
  name: "Notebook",
  internalNote: "supplier review",
});
console.log(safe); // { id: "p1", name: "Notebook" }

This produces a shallow object. Any nested values in publicProduct are still shared with the input object, so removing a top-level property does not establish independent ownership of nested data.

Common mistakes and debugging

  • Confusing rename syntax: { name: title } creates title, not name.
  • Expecting defaults for null: defaults apply only to undefined.
  • Rest not last: a rest element or property must be last; an array rest element cannot have a trailing comma.
  • Spreading a plain object into an array: [...plainObject] throws because a normal object is not iterable.
  • Wrong overwrite order: inspect spreads from left to right and place protected values last.
  • Assuming deep copy: compare nested references with copy.details === original.details.
  • Mutating a nested shared value: copy each object or array from the root through to the changed field.
  • Over-destructuring: deeply nested patterns can hide the data shape. Introduce intermediate variables when that makes the structure clearer.

When debugging, start with the level at which the unexpected behavior appears. Compare the root identity, then each nested identity along the path. A new root with an old nested reference is normal for a shallow copy, not evidence that spread failed.

Best practices

  • Destructure fields when doing so improves readability; do not unpack every property automatically.
  • Use domain-specific names when renaming, such as name: productName.
  • Use rest to omit known fields and spread to construct a new container.
  • Treat every spread or rest copy as shallow unless the code or API explicitly demonstrates otherwise.
  • Make merge precedence intentional and visible in the order of definitions.
  • Validate external changes before merging them.

Checkpoint

Use three cards labeled root product, details, and dimensions. For each spread in the nested update, replace the corresponding card and leave every other card in place. Predict === at each level. Then repeat with only { ...product } and observe that the deeper cards remain shared. This concrete path-copy exercise is more reliable than memorizing the vague rule that "spread makes a copy," which is often mistakenly interpreted as deep cloning.

Read three merge expressions from left to right and identify the final writer of every duplicate key. Include valid falsy defaults (0, false, and "") so that the exercise reinforces the precise rule: destructuring defaults respond only to undefined.

Exercises

Core

Destructure name as title, price, and a default stock of 0 from { name: "Cable", price: 9 }.

js
const source = { name: "Cable", price: 9 };
const { name: title, price, stock = 0 } = source;
console.log(title, price, stock);

Output: Cable 9 0

Practice

Merge default checkout settings with user settings. Ensure currency always remains "INR".

js
const defaults = { currency: "INR", delivery: "standard", giftWrap: false };
const userSettings = { currency: "USD", giftWrap: true };
const checkoutSettings = {
  ...defaults,
  ...userSettings,
  currency: "INR",
};

console.log(checkoutSettings);

Output:

text
{ currency: "INR", delivery: "standard", giftWrap: true }

Professional Extension

Update product.details.dimensions.width to 8 without mutating product. Prove that the root, details, and dimensions are new, while the original width remains 7.

js
const widerProduct = {
  ...product,
  details: {
    ...product.details,
    dimensions: {
      ...product.details.dimensions,
      width: 8,
    },
  },
};

console.log(widerProduct.details.dimensions.width); // 8
console.log(product.details.dimensions.width);      // 7
console.log(widerProduct !== product);              // true
console.log(widerProduct.details !== product.details); // true
console.log(
  widerProduct.details.dimensions !== product.details.dimensions,
); // true

Recap

Explain rest versus spread using their position in the syntax. When does a destructuring default run? Which value wins in { ...a, ...b }? Why can changing copy.details.color also change original.details.color? Finally, describe every container that must be copied for a nested update.

Official references

Deep-copy decision table

Choose a copying strategy from the data contract, not simply from the word "deep":

NeedChoiceCyclesImportant edge cases
Share one value intentionallyoriginal referenceyesMutations are shared
Immutable update at a known pathspread/rest path copyingn/aBest identity/performance; unchanged branches stay shared
Clone supported arbitrary datastructuredClone(value)yesClones Date, Map, Set, typed arrays; rejects functions, DOM nodes, and some host values
Serialize JSON-shaped dataJSON.stringify/JSON.parsenoDrops undefined object keys/functions/symbols; converts dates to text; NaN/Infinity to null; BigInt and cycles throw
Preserve behavior/custom instancesexplicit domain clone()dependsDefine exactly which fields and prototype behavior survive
js
const graph = { date: new Date("2024-01-01T00:00:00Z"), map: new Map([["x", 1]]) };
graph.self = graph;
const copy = structuredClone(graph);
console.assert(copy !== graph && copy.self === copy);
console.assert(copy.date instanceof Date && copy.map instanceof Map);

const jsonReady = { name: "Notebook", tags: ["paper"] };
const jsonCopy = JSON.parse(JSON.stringify(jsonReady));
console.assert(jsonCopy.tags !== jsonReady.tags);

structuredClone is not automatically the best answer. It recursively allocates the whole graph, loses class methods or prototype behavior where the domain semantics are not retained, and cannot clone functions. JSON round-tripping is useful at a wire boundary only when the contract is intentionally JSON. Before choosing an approach, test both values and identity, including undefined, null, dates, cycles, maps, sets, BigInt, and class instances.

Copy-choice interview questions

  1. What references change in a path-copy update, and which references remain shared?
  2. Why does JSON cloning turn a Date into a string and fail on a cycle?
  3. Which clone preserves a cycle and a Map without custom code?
  4. Why is a domain-specific clone safer for a class with methods or invariants?
  5. Write tests that distinguish a new root from independent nested ownership.
Reader page: /javascript/lesson/057/destructuring-rest-spread-and-copy-semantics