Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

JavaScript (ES2026)  Module 5 – Objects in depth

References, copying and structuredClone

See why assigning a JavaScript object copies a reference, then make shallow copies with spread and Object.assign and deep copies with structuredClone.

  • Beginner
  • 18 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 18 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain reference semantics for objects and arrays
  • Create shallow copies with spread and Object.assign, and deep copies with structuredClone
  • List what structuredClone cannot copy and what it changes on the way

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain reference semantics for objects and arrays
  • Create shallow copies with spread and Object.assign, and deep copies with structuredClone
  • List what structuredClone cannot copy and what it changes on the way

Before you start

On this page

A JavaScript variable never holds an object itself, only a reference to it, so const copy = cart creates no copy at all: copy and cart are two names for one object, and a change made through either name shows through both. Arrays, functions, Maps and every other object work the same way. To get a second, independent object you have to build one. A shallow copy, made with spread ({ ...cart }, [...list]) or Object.assign({}, cart), is a new outer object whose properties still point at the same nested objects. A deep copy copies every level; structuredClone(cart) makes one, and it also keeps Dates, Maps, Sets and cycles that a JSON round trip loses. It cannot copy functions, though, and a class instance comes back as a plain object. This lesson shows how references behave, compares the three ways of copying on one nested value, and practises the update that copies only what changes.

Two names, one object

A cart reached through two names and a function JavaScript · shared-reference.mjs
// Assigning an object copies the reference to it, never the object itself.
const cart = { owner: 'Asha', items: ['pen'] };
const sameCart = cart; // a second name for the same object

sameCart.items.push('ink');
sameCart.owner = 'Ravi';
console.log(cart); // the change shows through both names
console.log('one object:', cart === sameCart);

// A function receives the same reference, so it can change the caller's object.
function addGiftWrap(c) {
  c.items.push('gift wrap');
}
addGiftWrap(cart);
console.log(cart.items);

// Two objects with the same contents are still two different objects.
console.log('equal contents, same object?', { owner: 'Asha' } === { owner: 'Asha' });

// const fixes the name, not the object behind it.
const settings = { theme: 'light' };
settings.theme = 'dark';
console.log(settings);

// Pointing a name at a new object leaves the old object, and its other names, alone.
let draft = cart;
draft = { owner: 'Meera', items: [] };
console.log(cart.owner, '|', draft.owner);

Output

{ owner: 'Ravi', items: [ 'pen', 'ink' ] }
one object: true
[ 'pen', 'ink', 'gift wrap' ]
equal contents, same object? false
{ theme: 'dark' }
Ravi | Meera

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shared-reference.mjs

const sameCart = cart copies the reference, the arrow that leads to the object, so the push and the new owner made through sameCart are there when you look through cart. Passing an object to a function works the same way: addGiftWrap receives a reference to the caller’s cart and can change it, which is why a function that changes its argument surprises the code that called it.

Three consequences follow:

  • === compares references, not contents. Two literals with the same keys and values are two objects, so { owner: 'Asha' } === { owner: 'Asha' } is false, and a copy is never === its original.
  • const stops the name from pointing anywhere else; it does not stop the object from changing. settings.theme can be set even though settings is a constant.
  • Pointing a name at a new object, as draft = { … } does, changes nothing for the object the name pointed at before, or for the other names that still point at it.

Primitive values (numbers, strings, booleans, null, undefined, BigInts and symbols) cannot be changed in place, so copying a reference or a value makes no visible difference for them. The difference matters only for objects.

Two names share one cart object; a spread copy is a new object that shares the items array; structuredClone copies the array too.sameCart = cartspreadCopy = { ...cart }deep = structuredClone(cart)cart,sameCartone cartobjectone itemsarraya new cartobjectthe sameitems arraya new cartobjecta new itemsarraysharedits own

What an assignment, a spread copy and structuredClone share with the original

Text description of the diagram

The diagram shows one cart object, its nested `items` array, and three ways of getting another name or a copy.

  • `const sameCart = cart` creates no copy: both names point at the same cart object, so a change made through either name is seen through both.
  • `const spreadCopy = { ...cart }` creates a new outer object, but its `items` property points at the same `items` array as the original, so pushing an item through the copy changes the original's array.
  • `const deep = structuredClone(cart)` creates a new outer object and a new `items` array with new item objects, so nothing is shared with the original.

Shallow copies: spread and Object.assign

A spread copy and an Object.assign copy share the nested items JavaScript · shallow-copy.mjs
// Spread and Object.assign make a new outer object, but copy only one level.
const cart = { owner: 'Asha', coupon: null, items: [{ sku: 'pen', qty: 2 }] };
const spreadCopy = { ...cart };
const assignCopy = Object.assign({}, cart);

spreadCopy.owner = 'Ravi'; // a top-level property: only the copy changes
spreadCopy.items[0].qty = 5; // inside a nested object: the original changes too
assignCopy.items.push({ sku: 'ink', qty: 1 }); // the nested array is shared by all three

console.log(cart.owner, cart.items);
console.log('new outer object:', spreadCopy !== cart, '| items shared:', spreadCopy.items === cart.items, assignCopy.items === cart.items);

// Arrays: spread, slice() and Array.from() also copy one level.
const seats = [
  ['A1', 'A2'],
  ['B1', 'B2'],
];
const seatsCopy = [...seats];
seatsCopy[1][1] = 'taken';
console.log(seats);

// Object.assign writes into its first argument and returns it.
const target = { theme: 'light' };
const result = Object.assign(target, { theme: 'dark' }, { fontSize: 16 });
console.log(result === target, target);

Output

Asha [ { sku: 'pen', qty: 5 }, { sku: 'ink', qty: 1 } ]
new outer object: true | items shared: true true
[ [ 'A1', 'A2' ], [ 'B1', 'taken' ] ]
true { theme: 'dark', fontSize: 16 }

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shallow-copy.mjs

{ ...cart } builds a new object and copies the own enumerable properties of cart into it, one level deep. Setting spreadCopy.owner changes only the copy, because that property now lives on the new object. But the value of items is a reference, and the copy received the same reference, so spreadCopy.items[0].qty = 5 changed the shared item, and the push through assignCopy added to the one array that all three objects share. The output shows the original with quantity 5 and two items.

  • [...seats], seats.slice() and Array.from(seats) do the same for arrays: a new outer array that holds the same inner arrays, so writing a seat through the copy writes the original’s row.
  • Object.assign(target, ...sources) copies the sources’ own enumerable properties into target, then returns target. With {} as the target it makes a shallow copy; with an existing object it changes that object, which the last lines show. Later sources win, so Object.assign({}, defaults, saved) lets saved values override defaults.
  • Spread defines properties on the new object, while Object.assign assigns them, so a setter on the target, including the inherited __proto__ setter, runs for Object.assign and not for spread. For plain data the results are the same.

A shallow copy is often exactly what you need: when you will replace a nested object rather than change it, sharing it costs nothing and is safe.

Deep copies with structuredClone

structuredClone(value) is a built-in function of browsers and Node.js that copies a value with the structured clone algorithm of the HTML standard, the same algorithm that sends data between a page and a web worker. It walks the whole value and builds every object again. Here it is next to spread and a JSON round trip, JSON.parse(JSON.stringify(value)), on one nested order with a Date, a Map, an undefined field, one address object used twice, a BigInt and, separately, a cycle:

Spread, a JSON round trip and structuredClone on the same order JavaScript · clone-matrix.mjs
// The same nested value copied three ways: spread, a JSON round trip and structuredClone, compared on what survives.
const address = { city: 'Pune' };
const order = {
  items: [{ sku: 'pen', qty: 2 }],
  placedAt: new Date(0),
  tags: new Map([['gift', true]]),
  note: undefined,
  billing: address,
  shipping: address, // the same object twice
  units: 3n, // a BigInt
};
const looped = { name: 'loop' };
looped.self = looped; // a cycle

const methods = {
  spread: (value) => ({ ...value }),
  json: (value) => JSON.parse(JSON.stringify(value)),
  structuredClone: (value) => structuredClone(value),
};

const attempt = (fn) => {
  try {
    return fn();
  } catch (error) {
    return `throws ${error.name}`;
  }
};
const yesNo = (flag) => (flag ? 'yes' : 'no');

const checks = {
  'items is a new array': (copy) => yesNo(copy.items !== order.items),
  'placedAt is still a Date': (copy) => yesNo(copy.placedAt instanceof Date),
  'placedAt is a new Date': (copy) => yesNo(copy.placedAt !== order.placedAt),
  'tags is still a Map': (copy) => yesNo(copy.tags instanceof Map && copy.tags.get('gift') === true),
  'note is still a key': (copy) => yesNo(Object.hasOwn(copy, 'note')),
  'billing === shipping': (copy) => yesNo(copy.billing === copy.shipping),
};

const withoutBigInt = { ...order };
delete withoutBigInt.units; // JSON cannot write a BigInt at all, so the other rows use the order without it

const line = (label, cells) => console.log(`${label.padEnd(26)} ${cells.join('')}`.trimEnd());

line('check', Object.keys(methods).map((m) => m.padEnd(18)));
for (const [label, check] of Object.entries(checks)) {
  const cells = Object.values(methods).map((copyOf) => attempt(() => check(copyOf(withoutBigInt))).padEnd(18));
  line(label, cells);
}
const bigIntRow = Object.values(methods).map((copyOf) => attempt(() => typeof copyOf(order).units).padEnd(18));
line('units (a BigInt) gives', bigIntRow);
const cycleRow = Object.values(methods).map((copyOf) => attempt(() => {
  const copy = copyOf(looped);
  return copy.self === copy ? 'self is the copy' : 'self is original';
}).padEnd(18));
line('a cycle (looped.self)', cycleRow);

Output

check                      spread            json              structuredClone
items is a new array       no                yes               yes
placedAt is still a Date   yes               no                yes
placedAt is a new Date     no                yes               yes
tags is still a Map        yes               no                yes
note is still a key        yes               no                yes
billing === shipping       yes               no                yes
units (a BigInt) gives     bigint            throws TypeError  bigint
a cycle (looped.self)      self is original  throws TypeError  self is the copy

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node clone-matrix.mjs

Reading the table column by column:

  • Spread keeps every type, because it copies nothing below the top level: placedAt is still a Date and billing === shipping is still true, but they are the original’s Date and address, shared with the original.
  • The JSON round trip copies every level, but only what JSON can write. The Date becomes a string, the Map becomes an empty object, note disappears because JSON has no undefined, and the shared address turns into two separate copies. A BigInt or a cycle makes JSON.stringify throw a TypeError.
  • structuredClone copies every level and keeps the Date, the Map with its entry, the undefined field, the BigInt and the fact that billing and shipping are one object. A cycle is copied as a cycle: copy.self is the copy.

On Node.js 24.21.0, structuredClone was the only one of the three that kept every row intact. Typed arrays, ArrayBuffers, Sets, regular expressions and the standard Error types are copied too.

What structuredClone does not copy

The algorithm copies data, not behaviour. These are its limits:

Classes, functions, getters, symbol keys and frozen objects JavaScript · clone-limits.mjs
// What structuredClone does not copy: class identity, methods, getters, symbol keys, hidden properties, frozenness.
class Money {
  #paise; // a private field
  constructor(paise) {
    this.#paise = paise;
    this.currency = 'INR';
  }
  get amount() {
    return this.#paise / 100;
  }
  format() {
    return `${this.currency} ${this.amount.toFixed(2)}`;
  }
}

const price = new Money(12345);
const copy = structuredClone(price);
console.log(price.format(), '|', copy instanceof Money, copy);
console.log('copy.amount:', copy.amount, '| copy.format:', typeof copy.format);

// A function anywhere inside the value stops the whole copy.
try {
  structuredClone({ total: 10, recalc() {} });
} catch (error) {
  console.log(`${error.name} (${error.constructor.name}): ${error.message}`);
}

// A getter is read once and becomes a plain value; symbol keys and non-enumerable properties are left out.
const order = {
  get late() {
    return true;
  },
  [Symbol('audit')]: 'checked',
};
Object.defineProperty(order, 'internalId', { value: 7, enumerable: false });
const orderCopy = structuredClone(order);
console.log(Object.getOwnPropertyDescriptor(orderCopy, 'late'));
console.log('keys of the copy:', Reflect.ownKeys(orderCopy));

// Freezing is not part of the copy.
console.log('copy of a frozen object is frozen:', Object.isFrozen(structuredClone(Object.freeze({ a: 1 }))));

Output

INR 123.45 | false { currency: 'INR' }
copy.amount: undefined | copy.format: undefined
DataCloneError (DOMException): recalc() {} could not be cloned.
{ value: true, writable: true, enumerable: true, configurable: true }
keys of the copy: [ 'late' ]
copy of a frozen object is frozen: false

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node clone-limits.mjs

  • Functions cannot be cloned. One method anywhere inside the value makes the whole call throw a DataCloneError, a DOMException; nothing is returned. Symbols, WeakMaps, Promises and DOM nodes are refused the same way.
  • Class instances come back as plain objects. copy instanceof Money is false, the getter amount and the method format are gone because they lived on the class’s prototype, and the private field #paise is not copied. Only the own data property currency survives.
  • Getters on the object itself are read once, and the copy holds the value as a plain property.
  • Symbol keys and non-enumerable properties are left out: the copy of order has the single key late.
  • Frozen or sealed objects come back as ordinary, changeable objects.

So structuredClone suits data: settings, API responses, form state, records loaded from storage. For an object with methods, give its class a method that builds a new instance from its own data, such as clone() { return new Money(this.#paise); }, which also copies the private field.

Which way of copying to use
Criterion AssignmentSpread or Object.assignstructuredCloneJSON round trip
New outer object NoYesYesYes
Nested objects SharedSharedCopiedCopied
Dates, Maps, Sets, BigInt, undefined SharedSharedKeptChanged, lost or an error
Functions and methods SharedShared (same function)DataCloneErrorDropped
Class instances SharedPlain object, own properties onlyPlain object, data onlyPlain object, JSON values only
Cycles and shared objects SharedSharedKept as they areTypeError, or copied twice
When to choose A second name on purposeOne level changes and nested objects are replaced, not changedAn independent copy of dataData that is going to be JSON anyway

Copy only what changes

Deep-copying a whole state object to change one number wastes work, and it breaks a useful property: code that compares objects with === to see what changed would see that everything changed. The usual pattern copies the path to the change and reuses everything else. To change one item of a cart: a new cart ({ ...cart, items }), a new items array, a new object for the one item, and the very same objects for every other item. The original stays intact, and a check such as newCart.items[0] === cart.items[0] tells you that the first item did not change. The exercise below asks for exactly this, and the spread lesson later in this module does it for deeper data.

JSON Diff & Compare Paste the JSON of an object before and after a change to see which parts a copy really changed.

Exercise: change a quantity without changing the cart

Exercise · Easy · JavaScript

Change a quantity without changing the cart

A cart looks like { owner: 'Asha', coupon: null, items: [{ id: 'pen', qty: 2 }, { id: 'ink', qty: 1 }] }. Write updateQuantity(cart, itemId, qty) so that it returns the updated cart without changing the cart it was given. The version in update-quantity.mjs changes the item in place, which every other holder of the cart then sees.

  • Return a new cart object with a new items array. The changed item is a new object with the new qty.
  • Items that did not change are the same objects as before (reuse them; do not copy them).
  • A qty of 0 removes that item.
  • If no item has itemId, return the cart you were given, unchanged.
  • Every other property of the cart, such as owner and coupon, is kept.

The tests freeze the input cart, its array and its items, so any write to them throws a TypeError.

Starter code · update-quantity.mjs

// updateQuantity(cart, itemId, qty): the cart with one quantity changed (the exercise of lesson
// javascript/references-and-copying). This version changes the cart it is given. Make it return a new cart instead.

export function updateQuantity(cart, itemId, qty) {
  const item = cart.items.find((entry) => entry.id === itemId);
  if (item) item.qty = qty;
  return cart;
}
The sample tests · update-quantity.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { updateQuantity } from './update-quantity.mjs';

/** A cart whose object, array and items are all frozen: any write throws a TypeError in module code. */
function frozenCart() {
  const items = [Object.freeze({ id: 'pen', qty: 2 }), Object.freeze({ id: 'ink', qty: 1 }), Object.freeze({ id: 'nib', qty: 4 })];
  return Object.freeze({ owner: 'Asha', coupon: null, items: Object.freeze(items) });
}

test('changes the quantity of one item', () => {
  const result = updateQuantity(frozenCart(), 'ink', 3);
  assert.deepEqual(result.items, [
    { id: 'pen', qty: 2 },
    { id: 'ink', qty: 3 },
    { id: 'nib', qty: 4 },
  ]);
});

test('leaves the input cart exactly as it was', () => {
  const cart = frozenCart();
  updateQuantity(cart, 'pen', 7);
  assert.deepEqual(cart.items.map((item) => item.qty), [2, 1, 4]);
});

test('returns a new cart and array, and reuses the items that did not change', () => {
  const cart = frozenCart();
  const result = updateQuantity(cart, 'ink', 3);
  assert.notEqual(result, cart, 'the cart should be a new object');
  assert.notEqual(result.items, cart.items, 'items should be a new array');
  assert.notEqual(result.items[1], cart.items[1], 'the changed item should be a new object');
  assert.equal(result.items[0], cart.items[0], 'an unchanged item should be the same object');
  assert.equal(result.items[2], cart.items[2], 'an unchanged item should be the same object');
});

test('a quantity of 0 removes the item', () => {
  const result = updateQuantity(frozenCart(), 'pen', 0);
  assert.deepEqual(result.items.map((item) => item.id), ['ink', 'nib']);
});

test('an unknown id returns the same cart', () => {
  const cart = frozenCart();
  assert.equal(updateQuantity(cart, 'eraser', 5), cart);
});

test('keeps the other properties of the cart', () => {
  const result = updateQuantity(frozenCart(), 'nib', 1);
  assert.equal(result.owner, 'Asha');
  assert.equal(result.coupon, null);
  assert.deepEqual(Object.keys(result), ['owner', 'coupon', 'items']);
});
A hint

Build new objects only along the path that changes: { ...cart, items } copies the cart's own properties into a new object and replaces items. For the new array, cart.items.map(...) returns a new array: give back { ...item, qty } for the item you change and the item itself for every other one. filter gives a new array without the item when qty is 0.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Self-check

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does this program print?

    Read the code, then choose one answer.

    const order = { qty: 1 };
    const sameOrder = order;
    sameOrder.qty = 2;
    
    console.log(order.qty);
    Show the answer to question 1

    Answer: 2

    sameOrder = order copies the reference, so both names point at one object, and a change made through either name is seen through both. const only stops the name from pointing somewhere else; the object can still change.

  2. Question 2 of 6 What does this program print?

    Read the code, then choose one answer.

    const post = { tags: ['js'] };
    const draft = { ...post };
    draft.tags.push('objects');
    draft.title = 'Copies';
    
    console.log(post.tags.length, 'title' in post);
    Show the answer to question 2

    Answer: 2 false

    Spread copies one level. draft is a new object, so adding title to it leaves post alone, but draft.tags is the same array as post.tags, so the push shows in both.

  3. Question 3 of 6 What does this program print?

    Read the code, then choose one answer.

    const copy = JSON.parse(JSON.stringify({ when: new Date(0), note: undefined }));
    
    console.log(typeof copy.when, 'note' in copy);
    Show the answer to question 3

    Answer: string false

    JSON.stringify writes a Date as an ISO date string, and JSON.parse has no way to know it was a Date, so the copy holds a string. A property whose value is undefined is left out of the JSON, so the copy has no note key. structuredClone keeps both.

  4. Question 4 of 6 structuredClone(new Temperature(30)), where Temperature stores this.celsius and has a fahrenheit getter. What comes back?

    Choose one answer.

    Show the answer to question 4

    Answer: A plain object with an own celsius property and no fahrenheit getter; instanceof Temperature is false

    The structured clone algorithm copies an ordinary object's own enumerable properties into a new plain object. The prototype, and with it every method and getter of the class, is not part of the copy.

  5. Question 5 of 6 const box = { items: [{ qty: 1 }] }. After which of these copies is copy.items[0] a different object from box.items[0]? Choose every right one.

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • JSON.parse(JSON.stringify(box))
    • structuredClone(box)

    structuredClone and the JSON round trip build every level again, so the item is new. Spread and Object.assign copy only the outer object, so the copy's items is the original array with the original item. The JSON round trip works here only because the data is plain: numbers, arrays and objects.

  6. Question 6 of 6 What does this program print?

    Read the code, then choose one answer.

    let settings = { theme: 'light' };
    const saved = settings;
    settings = { ...settings, theme: 'dark' };
    
    console.log(saved.theme, settings.theme);
    Show the answer to question 6

    Answer: light dark

    The third line builds a new object and points settings at it. saved still points at the first object, which nothing changed. Replacing an object instead of changing it is what makes this kind of update safe to share.

Interview questions

Fresher · How does a shallow copy of a JavaScript object differ from a deep copy?

A shallow copy is a new outer object whose properties hold the same values as the original, so nested objects and arrays are shared: changing copy.address.city also changes the original’s address. Spread syntax, Object.assign, slice() and Array.from() make shallow copies. A deep copy rebuilds every level, so the copy shares nothing with the original and can be changed freely. structuredClone() makes deep copies and keeps Dates, Maps, Sets, BigInts, undefined values and cycles, but it throws for functions and turns class instances into plain objects. A JSON round trip also copies deeply, but only JSON data: Dates become strings and undefined values disappear. Plain assignment makes no copy at all; it only adds a second name for the same object.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A settings form resets with state = { ...DEFAULTS }. A user switches off email alerts, presses Reset, and the alerts stay off; worse, every new user now starts with them off. DEFAULTS is a const and nobody assigns to it. Explain what happened, fix it, and say how you stop it coming back.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A code base copies data with JSON.parse(JSON.stringify(x)) in 40 places, and a pull request replaces every one with structuredClone(x). The tests pass. Which results change, what can now throw that did not throw before, and where would you keep the JSON round trip?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Variables hold references to objects. Assigning or passing an object shares it; === compares references, and const protects the name, not the object.
  • Spread, Object.assign, slice and Array.from make shallow copies: a new outer object or array whose nested objects are still shared.
  • structuredClone(value) makes a deep copy that keeps Dates, Maps, Sets, BigInts, undefined, shared objects and cycles; a JSON round trip loses or breaks most of those.
  • structuredClone copies data only: functions throw a DataCloneError, and class instances, getters, symbol keys, hidden properties and frozenness do not survive.
  • To change nested data without changing the original, copy the path to the change and reuse everything else.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.