References, copies and structuredClone
Predict when two names share one object, make shallow and deep copies, and know what Object.freeze does and does not protect.
What you will learn
- Tell value copying of primitives from reference sharing of objects
- Make shallow copies with spread and slice and say what they share
- Make deep copies with structuredClone and know its limits
- Explain why Object.freeze is shallow
On this page
The fundamentals lesson on memory and references explains the difference between a value and a reference to a value. JavaScript has exactly this split. Primitives (numbers, strings, booleans, null, undefined, symbols and BigInts) are copied when you assign them. Objects, which include arrays, functions, Map (lesson 29), Set and Date, are handled through references: assigning one gives the new name the same object.
Aliasing
let a = 5;let b = a;b = 6;console.log(a, b);
const list1 = [1, 2, 3];const list2 = list1;list2.push(4);console.log(list1, list1 === list2);
const other = [1, 2, 3, 4];console.log(list1 === other);5 6[ 1, 2, 3, 4 ] truefalseFunction arguments follow the same rule:
function addItem(list, item) { list.push(item);}const cart = ["pen"];addItem(cart, "ink");console.log(cart);
function reassign(list) { list = ["new"]; return list;}console.log(reassign(cart), cart);[ 'pen', 'ink' ][ 'new' ] [ 'pen', 'ink' ]b = acopied the number, so changingbleftaalone.list2 = list1copied the reference. There is one array, andpushthrough either name changes it.===on two objects compares identity, solist1 === otherisfalsealthough the contents are equal.- Function arguments work the same way.
addItemreceived the reference and changed the caller’s array. - Reassigning a parameter, as
reassigndoes, changes only the local name. The caller’scartis unchanged. Changing the object is visible outside. Pointing the name somewhere else is not.
Shallow copies
Spread ({ ...obj }, [...arr]), slice(), Object.assign({}, obj) and Array.from(arr) all make a shallow copy: a new outer object whose properties point at the same inner values.
const original = { name: "Ada", scores: [90, 80], address: { city: "London" },};
const copy = { ...original };copy.name = "Grace";copy.scores.push(70);copy.address.city = "Paris";
console.log(original.name);console.log(original.scores);console.log(original.address.city);
console.log(copy.scores === original.scores);Ada[ 90, 80, 70 ]ParistrueNested arrays behave the same way:
const arr = [[1], [2]];const arrCopy = arr.slice();arrCopy[0].push("x");console.log(arr);[ [ 1, 'x' ], [ 2 ] ]Replacing a property of the copy (copy.name = "Grace") left the original alone, because that changed the copy’s own slot. But copy.scores.push(70) and copy.address.city = "Paris" reached through to the objects that both share, so the original changed too. The array example shows the same with nested arrays.
A shallow copy is right when the data has only primitives, or when you are about to replace the nested parts anyway, as the merge in the last lesson did.
Deep copies with structuredClone
structuredClone(value) makes a deep copy: new objects all the way down. It is a global function, available in Node and in browsers. The example below uses Date (a date and time value, taught in the intermediate level) and Set (lesson 29) only as examples of values that a deep copy has to understand.
const original = { name: "Ada", scores: [90, 80], address: { city: "London" }, born: new Date("1815-12-10"), tags: new Set(["maths"]),};
const deep = structuredClone(original);deep.scores.push(70);deep.address.city = "Paris";
console.log(original.scores, original.address.city);console.log(deep.scores, deep.address.city);console.log(deep.born instanceof Date, deep.tags);console.log(deep.born === original.born);[ 90, 80 ] London[ 90, 80, 70 ] Paristrue Set(1) { 'maths' }falseIt copes with circular references and refuses to copy functions:
const loop = { name: "loop" };loop.self = loop;const loopCopy = structuredClone(loop);console.log(loopCopy.self === loopCopy);
try { structuredClone({ run() {} });} catch (error) { console.log(error.name);}trueDataCloneErrorA class instance comes back as a plain object. (Classes are taught in the intermediate level. Here Point is just an object with a method.)
class Point { constructor(x) { this.x = x; } show() { return this.x; }}const clone = structuredClone(new Point(1));console.log(clone instanceof Point, clone);false { x: 1 }- Nested arrays and objects are separate in the copy.
- It understands
Date,Set,Map, typed arrays,RegExpand many other built-in types, so the clone’sbornis a realDateandtagsis a realSet. - It handles circular references:
loopCopy.self === loopCopy. - It cannot copy functions. It throws a
DataCloneError. - It copies class instances as plain objects. The clone of a
Pointhas the data (x: 1) but is not an instance ofPoint, so it has lost its methods.
Compared with the older JSON.parse(JSON.stringify(x)) trick, structuredClone keeps dates, sets and maps and handles cycles. The JSON lesson at the end of this module shows what that trick loses.
Object.freeze is shallow
Object.freeze(obj) stops you adding, removing or changing the object’s own properties. It does not freeze the objects those properties point at.
const settings = Object.freeze({ theme: "dark", window: { width: 800 },});
try { settings.theme = "light";} catch (error) { console.log(error.name);}console.log(settings.theme);TypeErrordarkNested objects stay writable, and a frozen array rejects push:
settings.window.width = 1024;console.log(settings.window.width);console.log(Object.isFrozen(settings), Object.isFrozen(settings.window));
const items = Object.freeze([1, 2, 3]);try { items.push(4);} catch (error) { console.log(error.name);}1024true falseTypeErrorBecause modules run in strict mode, writing to a frozen object throws a TypeError. (In sloppy-mode scripts the write is silently ignored, which hides bugs.) settings.window.width = 1024 still worked, since window was not frozen, and Object.isFrozen(settings.window) is false. A frozen array rejects push.
To freeze deeply you would freeze each nested object yourself, usually with a recursive function. In most programs it is simpler to treat data as read-only by convention and to return new objects instead of changing old ones.
Common pitfalls
- Treating
const copy = originalas a copy. It is a second name. - Assuming spread or
slicecopies deeply. Only the top level is new. - Using
fillwith an object.new Array(2).fill([])puts the same array in every slot. - Sharing a default object. A default value built once and spread into many objects shares its nested arrays.
- Mutating a function’s arguments. Callers do not expect it. Return a new value instead.
- Cloning something that holds functions or class instances.
structuredClonethrows on functions and drops the prototype of class instances.
Exercises
Each section of this program has an aliasing bug. Read it, predict what is printed, then fix each one.
// Bug 1: the "copy" is the same arrayconst original = [3, 1, 2];const sortedCopy = original;sortedCopy.sort();console.log(original);
// Bug 2: a shared default objectconst defaults = { tags: [] };const post1 = { ...defaults };const post2 = { ...defaults };post1.tags.push("news");console.log(post2.tags);Bugs 3 and 4:
// Bug 3: fill with one objectconst grid = new Array(2).fill([]);grid[0].push("x");console.log(grid);
// Bug 4: a function that changes its argumentfunction addTax(order) { order.total = order.total * 1.2; return order;}const order = { total: 100 };addTax(order);console.log(order.total);Bug 5:
// Bug 5: a copy that is only one level deepconst rows = [{ n: 2 }, { n: 1 }];const rowsCopy = [...rows];rowsCopy[0].n = 99;console.log(rows);Show solution
Running the buggy program prints this:
[ 1, 2, 3 ][ 'news' ][ [ 'x' ], [ 'x' ] ]120[ { n: 99 }, { n: 1 } ]original became sorted, post2.tags has news in it, both rows of grid hold x, order.total changed to 120, and the first row of rows was changed through the copy.
Here is the fixed version:
// Fix 1: copy before sorting (or use toSorted)const original = [3, 1, 2];const sortedCopy = original.toSorted();console.log(original, sortedCopy);
// Fix 2: build a new tags array for every postconst makePost = () => ({ tags: [] });const post1 = makePost();const post2 = makePost();post1.tags.push("news");console.log(post2.tags);[ 3, 1, 2 ] [ 1, 2, 3 ][]Fixes 3 and 4:
// Fix 3: make a separate array for every rowconst grid = Array.from({ length: 2 }, () => []);grid[0].push("x");console.log(grid);
// Fix 4: return a new objectfunction addTax(order) { return { ...order, total: order.total * 1.2 };}const order = { total: 100 };const taxed = addTax(order);console.log(order.total, taxed.total);[ [ 'x' ], [] ]100 120Fix 5:
// Fix 5: copy each row tooconst rows = [{ n: 2 }, { n: 1 }];const rowsCopy = rows.map((row) => ({ ...row }));rowsCopy[0].n = 99;console.log(rows);[ { n: 2 }, { n: 1 } ]toSortedreturns a sorted copy. The original keeps its order.- A factory function builds a fresh
tagsarray for every post, instead of spreading one shared default. Array.from({ length: 2 }, () => [])calls the function once for each slot, so every row is its own array.addTaxreturns a new object with a changedtotal. The caller’s order is untouched.- Mapping each row to
{ ...row }copies the rows as well as the array.
Predict what this prints before you run it. It prints two values on one line: a number, then a boolean.
const team = Object.freeze({ name: "core", members: ["Ada"] });team.members.push("Grace");console.log(team.members.length, Object.isFrozen(team));Show solution
const team = Object.freeze({ name: "core", members: ["Ada"] });team.members.push("Grace");console.log(team.members.length, Object.isFrozen(team));2 trueIt prints 2 true. team is frozen, so you cannot add or reassign its properties. The members array is a separate object that was not frozen, so push works.
Write addScore(student, score) that returns a new student record with the score added to the end of scores, without changing the original or its array. Try it on { name: "Ada", scores: [90] } with the score 75, then print the original and the new record. The output should be { name: 'Ada', scores: [ 90 ] } and then { name: 'Ada', scores: [ 90, 75 ] }.
Show solution
function addScore(student, score) { return { ...student, scores: [...student.scores, score] };}
const ada = { name: "Ada", scores: [90] };const updated = addScore(ada, 75);console.log(ada);console.log(updated);{ name: 'Ada', scores: [ 90 ] }{ name: 'Ada', scores: [ 90, 75 ] }Spreading student copies name and the reference to scores into a new record. The scores key written after it then replaces that shared reference with a new array built from the old items plus the new score. Both levels are new, so the original stays as it was.
Summary
- Primitives are copied on assignment. Objects are shared: two names can point at one object.
- Spread,
slice,Object.assignandArray.frommake shallow copies. Nested objects stay shared. structuredClonemakes a deep copy, handles dates, sets, maps and cycles, throws on functions and turns class instances into plain objects.Object.freezeis shallow. In a module, writing to a frozen object throws.
Next: Map and Set, collections that fix some weaknesses of plain objects and arrays.