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.
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
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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' }isfalse, and a copy is never===its original.conststops the name from pointing anywhere else; it does not stop the object from changing.settings.themecan be set even thoughsettingsis 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.
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
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
{ ...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()andArray.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 intotarget, then returnstarget. 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, soObject.assign({}, defaults, saved)lets saved values override defaults.- Spread defines properties on the new object, while
Object.assignassigns them, so a setter on the target, including the inherited__proto__setter, runs forObject.assignand 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:
// 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:
placedAtis still a Date andbilling === shippingis 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,
notedisappears because JSON has noundefined, and the shared address turns into two separate copies. A BigInt or a cycle makesJSON.stringifythrow aTypeError. - structuredClone copies every level and keeps the Date, the Map with its entry, the
undefinedfield, the BigInt and the fact that billing and shipping are one object. A cycle is copied as a cycle:copy.selfis 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:
// 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, aDOMException; nothing is returned. Symbols,WeakMaps, Promises and DOM nodes are refused the same way. - Class instances come back as plain objects.
copy instanceof Moneyisfalse, the getteramountand the methodformatare gone because they lived on the class’s prototype, and the private field#paiseis not copied. Only the own data propertycurrencysurvives. - 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
orderhas the single keylate. - 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.
| Criterion | Assignment | Spread or Object.assign | structuredClone | JSON round trip |
|---|---|---|---|---|
| New outer object | No | Yes | Yes | Yes |
| Nested objects | Shared | Shared | Copied | Copied |
| Dates, Maps, Sets, BigInt, undefined | Shared | Shared | Kept | Changed, lost or an error |
| Functions and methods | Shared | Shared (same function) | DataCloneError | Dropped |
| Class instances | Shared | Plain object, own properties only | Plain object, data only | Plain object, JSON values only |
| Cycles and shared objects | Shared | Shared | Kept as they are | TypeError, or copied twice |
| When to choose | A second name on purpose | One level changes and nested objects are replaced, not changed | An independent copy of data | Data 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.
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
itemsarray. The changed item is a new object with the newqty. - Items that did not change are the same objects as before (reuse them; do not copy them).
- A
qtyof 0 removes that item. - If no item has
itemId, return the cart you were given, unchanged. - Every other property of the cart, such as
ownerandcoupon, 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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?
Key takeaways
- Variables hold references to objects. Assigning or passing an object shares it;
===compares references, andconstprotects the name, not the object. - Spread,
Object.assign,sliceandArray.frommake 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
- HTML Living Standard: structuredClone() (WHATWG)
- HTML Living Standard: StructuredSerializeInternal (WHATWG)
- ECMAScript 2026 Language Specification: Object.assign (Ecma International)
- ECMAScript 2026 Language Specification: CopyDataProperties (Ecma International)
- ECMAScript 2026 Language Specification: JSON.stringify (Ecma International)
- Node.js v24 documentation: structuredClone() (OpenJS Foundation (Node.js))
- structuredClone() global function (MDN Web Docs)
- The structured clone algorithm (MDN Web Docs)
- Spread syntax (...) (MDN Web Docs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress