Learn JS Series (#46) - Shallow vs Deep Copy, and structuredClone
Learn JS Series (#46) - Shallow vs Deep Copy, and structuredClone
What will I learn
- You will learn precisely why copying nested objects is tricky: references, not values;
- the difference between a shallow copy and a deep copy, and how to see it in the console;
- which everyday techniques (spread,
Object.assign,slice) are shallow, and what that costs you; - how
structuredClonemakes a true deep copy in one call, and exactly where its limits are; - why the old
JSON.parse(JSON.stringify(...))hack is worse, and what it silently destroys; - practical strategies for immutable updates of deeply nested data without cloning the whole thing.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-45 read, especially references (ep13), spread (ep20), pure functions (ep28), and last time's object methods (ep45).
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
- Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
- Learn JS Series (#35) - The Prototype Chain: How Property Lookup Really Works
- Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance
- Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step
- Learn JS Series (#38) - The class Syntax: Sugar Over Prototypes, and What It Hides
- Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again
- Learn JS Series (#40) - Static Members, Static Blocks, and Class-Level State
- Learn JS Series (#41) - Private Fields (#) and True Encapsulation
- Learn JS Series (#42) - instanceof, isPrototypeOf, and Checking Types Honestly
- Learn JS Series (#43) - Mixins: Sharing Behavior Without a Single Inheritance Line
- Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes
- Learn JS Series (#45) - Object Methods: assign, keys, values, entries, fromEntries
- Learn JS Series (#46) - Shallow vs Deep Copy, and structuredClone (this post)
Learn JS Series (#46) - Shallow vs Deep Copy, and structuredClone
Last episode I left you hanging on purpose. We spent the whole of #45 treating objects as data -- iterating them, filtering them, merging them with Object.assign and spread -- and right at the end I flagged the one thing that quietly ruins people's afternoons: every copy we made was shallow. I promised the fix would be the very next episode, and here we are. So today is all about the difference between copying the outside of an object and copying it all the way down, why nested data ends up secretly shared, and the modern one-liner that finally makes a genuine, independent clone. This is one of those topics that separates people who "know some JavaScript" from people who actually understand what their variables point at -- so grab a coffee, open a console, and let's settle it properly. ;-)
Solutions to Episode 45 Exercises
Exercise 1 - filtering an object down to a new one:
const inventory = { apples: 5, pears: 0, plums: 3 };
const inStock = Object.fromEntries(
Object.entries(inventory).filter(([, count]) => count > 0)
);
console.log(inStock); // { apples: 5, plums: 3 }
console.log(inventory); // { apples: 5, pears: 0, plums: 3 } - unchanged
The insight: the entries -> filter -> fromEntries round-trip filters an object's properties while leaving the original completely intact. Note the [, count] in the filter callback -- an empty first slot meaning "I do not care about the key here, only the value".
Exercise 2 - transforming keys and values together:
const prices = { apple: 1, pear: 2 };
const shout = Object.fromEntries(
Object.entries(prices).map(([k, v]) => [k.toUpperCase(), v * 2])
);
console.log(shout); // { APPLE: 2, PEAR: 4 }
The insight: the map transforms both halves of each [key, value] pair before fromEntries rebuilds the object -- key transformation and value transformation in a single pass.
Exercise 3 - demonstrating the shallow trap on purpose:
const original = { nested: { x: 1 } };
const copy = { ...original };
copy.nested.x = 99;
console.log(original.nested.x); // 99 - the nested object was shared, not copied
The insight: spread copied the top-level reference to nested, so both objects point at the exact same inner object. Reach into that inner object through either name and the other one sees it too.
And that third exercise, funnily enough, IS today's topic in miniature. Let's understand it fully now, and -- more importantly -- let's learn how to fix it.
Why copying is really about references
Recall from episode 13 that objects and arrays in JavaScript are handled by reference. A variable does not hold the object itself; it holds a reference (think of it as a pointer, or a name-tag) to an object living somewhere in memory. Primitives -- numbers, strings, booleans -- are different: they behave as if the value itself is copied around. That single distinction is the root of everything in this episode. When you "copy" an object, the crucial question is: did you copy the reference (so both variables now point at one and the same object), or did you build a genuinely new object with the same contents?
const a = { value: 1 };
const b = a; // NOT a copy: b holds the SAME reference as a
b.value = 2;
console.log(a.value); // 2 - a and b are two names for one object
const c = { ...a }; // a real copy: c is a brand-new object
c.value = 3;
console.log(a.value); // still 2 - c is independent (at the top level, anyway)
const b = a copied the reference, so a and b are two labels stuck on the same box. Mutating through b is mutating through a, because there is only one box. const c = { ...a } genuinely made a new box. But "a new box" is only half the story, and the other half is where quit some bugs come from -- because of nested data, a new outer box can still contain the very same inner boxes as the original.
Shallow copy: exactly one level deep
A shallow copy creates a new top-level object, and it copies each property value as it is. For primitive values, that is a real, independent copy -- change one, the other is untouched. But for values that are themselves objects or arrays, the shallow copy copies only the reference to that nested thing. The result: your new object and the original still share every nested object and array between them.
const user = {
name: "scipio", // primitive value
address: { city: "amsterdam" }, // nested object
tags: ["admin", "dev"], // nested array
};
const shallow = { ...user }; // a shallow copy
shallow.name = "alice"; // independent: only 'shallow' changes
shallow.address.city = "berlin"; // SHARED: this mutates the original's address too
shallow.tags.push("new"); // SHARED: this mutates the original's tags too
console.log(user.name); // "scipio" - safe, top-level primitive was copied
console.log(user.address.city); // "berlin" - CHANGED, the nested object was shared
console.log(user.tags); // ["admin", "dev", "new"] - CHANGED, nested array shared
Look carefully at what happened. Reassigning shallow.name was totally safe, because name held a primitive string sitting directly on the top level, and reassigning it just repointed shallow's own name slot. But shallow.address and user.address are two references to the one and only address object, so reaching into it and setting .city was seen by both. Same story with tags: push mutates the array in place, and it is the same array under both names.
Here is the part to memorise: spread ({ ...obj }), Object.assign, Array.prototype.slice, and Array.from are ALL shallow. Every single one of them. They are perfect when your data is flat, or when you only ever reassign top-level properties. But the instant your object contains nested objects or arrays, they leak mutations straight through to the original. This is the number-one copy surprise in JavaScript, and now you know exactly why it happens, which means you will never again be mystified when "my copy changed the original".
A quick way to prove to yourself whether two variables share a nested reference is the === identity check we discussed back in episode 13. Two object references are === only when they are literally the same object in memory:
const original = { list: [1, 2, 3] };
const shallow = { ...original };
console.log(original.list === shallow.list); // true - SAME array, shared!
console.log(original === shallow); // false - different top-level objects
That true on the nested list is the smoking gun. The top-level objects differ (false), but the inner array is shared (true). Whenever you are unsure whether a copy is safe to mutate, this two-line check tells you the truth immediately.
Deep copy: all the way down
A deep copy recursively duplicates everything -- the top-level object, and every nested object and array inside it, at every level -- producing a completely independent clone with no shared references anywhere. Mutating any part of a deep copy can never touch the original, because they have nothing in common except their shape. For years this was annoyingly hard to do correctly, but modern JavaScript ships a proper tool for it:
// deep copy with structuredClone (built into modern browsers and Node 17+):
const user = { name: "scipio", address: { city: "amsterdam" }, tags: ["admin"] };
const deep = structuredClone(user);
deep.address.city = "berlin"; // fully independent now
deep.tags.push("new");
console.log(user.address.city); // "amsterdam" - unchanged, genuinely cloned
console.log(user.tags); // ["admin"] - unchanged
console.log(user.address === deep.address); // false - separate objects, as it should be
structuredClone(value) is a global function that produces a genuine deep copy. It is the modern, correct, no-libraries-required answer to "give me an independent copy of this nested data". Before it existed (and it is relatively recent -- it landed in browsers and in Node around Node 17), people reached for hacks and pulled in helper libraries just to clone an object. Now the platform does it for you, and it does it better than most of those hacks ever did. Having said that, "better" does not mean "magic" -- it has real, specific limits you must know, or it will bite you in a different way.
structuredClone's capabilities and its limits
First, the good news, because it is genuinely impressive. Beyond plain objects and arrays, structuredClone correctly clones a whole roster of built-in types that the old tricks mangled or refused: Date, Map, Set, RegExp, ArrayBuffer and the typed arrays, Blob, and -- this one is the real party trick -- circular references, where an object refers back to itself somewhere down its own tree. A naive recursive cloner would loop forever on that; structuredClone handles it out of the box.
const data = {
when: new Date(),
lookup: new Map([["a", 1]]),
unique: new Set([1, 2, 3]),
};
data.self = data; // a CIRCULAR reference - the object points back at itself
const clone = structuredClone(data);
console.log(clone.when instanceof Date); // true - real Date, not a string
console.log(clone.lookup.get("a")); // 1 - Maps survive intact
console.log(clone.unique.has(2)); // true - Sets survive intact
console.log(clone.self === clone); // true - the cycle was preserved, not exploded
Now the limits, and please read these, because they are exactly where people get surprised the second time (after they have learned to stop using shallow copies). structuredClone uses the "structured clone algorithm", which understands data, not behaviour. So:
// LIMIT 1: it CANNOT clone functions - it throws a DataCloneError
const withMethod = { greet() { return "hi"; }, x: 1 };
// structuredClone(withMethod); // throws: could not be cloned
// LIMIT 2: it DROPS the prototype - a class instance becomes a plain object
class Point {
constructor(x, y) { this.x = x; this.y = y; }
distance() { return Math.hypot(this.x, this.y); }
}
const p = new Point(3, 4);
const pClone = structuredClone(p);
console.log(pClone.x, pClone.y); // 3 4 - the DATA came across fine
console.log(pClone instanceof Point); // false - it is a plain object now
// pClone.distance(); // would throw: distance is not a function
So the three big ones to remember: structuredClone cannot clone functions (it throws), it loses the prototype (a cloned class instance becomes a plain object with the data but none of the methods, and instanceof returns false), and it cannot clone things like DOM nodes. That makes it ideal for plain data -- the kind of thing you might store, cache, or send over a network -- but the wrong tool for cloning objects that carry methods or class identity. For those, you either write a purpose-built clone (a constructor that copies fields, a clone() method), or -- much better in my experience -- you design your data so you do not need to clone behaviour in the first place. Data is data; behaviour lives on prototypes and modules. Keep them apart and this whole category of problem mostly evaporates.
The old JSON trick, and why it is worse
You will still run into an older deep-copy idiom absolutely everywhere in existing code: JSON.parse(JSON.stringify(obj)). It serializes the object to a JSON string and then parses that string back into a fresh object graph, which does, technically, produce a deep copy of plain data. It works. But it is inferior to structuredClone in several concrete ways, and knowing them is basically the whole justification for why the newer tool exists:
const obj = {
when: new Date("2026-08-13"),
value: undefined,
fn: () => 1,
count: NaN,
big: 10n,
};
const jsonClone = JSON.parse(JSON.stringify(obj));
console.log(jsonClone.when); // "2026-08-13T00:00:00.000Z" - a STRING, not a Date!
console.log("value" in jsonClone); // false - undefined properties are silently DROPPED
console.log("fn" in jsonClone); // false - functions are silently DROPPED
console.log(jsonClone.count); // null - NaN gets mangled into null
// JSON.stringify(obj) with 'big' actually throws: BigInt can't be serialized
Count the casualties. The JSON trick turns Date objects into plain strings (you lose the type entirely -- it looks fine until some later code calls .getFullYear() and explodes), it silently drops properties whose value is undefined or a function, it mangles NaN and Infinity into null, it throws outright on BigInt, and it cannot handle Map, Set, or circular references (it throws on a cycle). structuredClone handles the dates, the Maps, the Sets, and the cycles correctly, and drops the truly un-clonable stuff (functions) with a clear error instead of a silent data loss. So the rule of thumb is simple: prefer structuredClone for deep-copying data. Reserve JSON.parse(JSON.stringify(...)) for the narrow case where you specifically want JSON-compatible output anyway (for example, you were about to send it as JSON regardless) and you know for a fact there are no dates, no undefined, no functions, and no cycles to lose.
Immutable nested updates without a full clone
Now, here is a trap in the opposite direction, and it is one I see beginners fall into the moment they learn about deep copy: reaching for structuredClone to change a single nested value. Deep-copying an entire object graph just to flip one field is wasteful -- you are duplicating megabytes of untouched data to edit a few bytes. The functional approach (remember purity, episode 28, and the immutable patterns we will lean on heavily later) is to build a new object that shares the unchanged parts and only replaces the branch you are actually updating. You do this with nested spread, copying only along the path from the root down to the thing you are changing:
const state = {
user: { name: "scipio", address: { city: "amsterdam" } },
count: 0,
};
// update the deeply nested city immutably, copying ONLY along the path being changed:
const next = {
...state,
user: {
...state.user,
address: { ...state.user.address, city: "berlin" },
},
};
console.log(next.user.address.city); // "berlin"
console.log(state.user.address.city); // "amsterdam" - original untouched
console.log(next.count === state.count); // true - unchanged branch is shared (fine!)
console.log(next.user === state.user); // false - this branch WAS on the changed path
Each level along the path being changed gets a fresh spread copy: a new top-level object, a new user, a new address. But count -- which sits off the path -- is simply shared by reference between state and next. And that sharing is completely safe, precisely because we never mutate shared data -- we only ever build new objects. This "copy along the path, share the rest" pattern is how immutable state updates work in real applications (it is exactly what libraries like React and Redux do under the hood on every state change), it is dramatically cheaper than deep-cloning the entire tree on every edit, and it keeps your data flow pure and predictable. When the nesting gets really deep this does get verbose -- which is exactly why lenses and helper libraries exist, and we will meet those approaches much later in the series -- but the core idea never changes: it is just nested spread, all the way down the path you touched.
A quick look sideways: how Python and other languages handle this
Quite a few of you arrived here from the Learn Python Series, so let's put the two side by side, because it genuinely sharpens the concept. This shallow-versus-deep distinction is NOT a JavaScript quirk -- it is a fundamental property of any language that uses reference semantics for compound values, which is most of them. Python has the exact same split, it just names the tools differently and puts them in a module:
import copy
original = {"name": "scipio", "address": {"city": "amsterdam"}}
shallow = original.copy() # or dict(original) - both SHALLOW, like JS spread
shallow["address"]["city"] = "berlin"
print(original["address"]["city"]) # "berlin" - shared nested dict, same trap as JS!
deep = copy.deepcopy(original) # the deep clone, like structuredClone
deep["address"]["city"] = "rotterdam"
print(original["address"]["city"]) # "berlin" - untouched by the deep copy
The parallels are almost one-to-one. Python's dict.copy() (and dict(other), and {**other}) are shallow, exactly like JavaScript's spread and Object.assign. Python's copy.deepcopy() is the equivalent of structuredClone -- a genuine recursive clone -- and, nota bene, it also handles circular references gracefully, just like structuredClone does. The lesson is bigger than either language: once you internalise "variables hold references to compound values, and copying the reference is not copying the value", you will spot this trap in Python, in Ruby, in Java, in C# -- everywhere. It is the same idea wearing different clothes. Master it once here and you have master it forever, in every language you will ever touch.
Try it yourself
- Create an object with a nested array and a nested object. Make a shallow copy with spread, then mutate BOTH a top-level primitive and a nested value on the copy, and print the original to show which change leaked through. In one sentence, explain why exactly one of your two mutations affected the original and the other did not.
- Deep-clone that same object with
structuredClone, mutate the nested values on the clone, and confirm the original is completely unchanged (use a===identity check on a nested branch to prove they are separate objects). Then try tostructuredClonean object that contains a method, and note precisely what happens. - Given a three-levels-deep state object, write an immutable update that changes ONE deeply nested value using nested spread, copying only along the path, and leaving the original intact. Then verify with
===that an untouched sibling branch is still shared between the old and new state, and explain in one sentence why that sharing is safe here.
So what did we actually cover?
- Copying is about references:
const b = ashares one and the same object; building a new object is a fundamentally different operation with different consequences. - A shallow copy duplicates only the top level -- nested objects and arrays are shared by reference. Spread,
Object.assign,Array.prototype.slice, andArray.fromare ALL shallow. A===check on a nested branch proves whether it is shared. - A deep copy recursively duplicates everything, yielding a fully independent clone with no shared references at any depth.
structuredClone(value)is the modern deep-copy tool: it handlesDate,Map,Set,RegExp, typed arrays, and even circular references, but it CANNOT clone functions (it throws), it loses the prototype (a class instance becomes a plain object,instanceofgoesfalse), and it rejects DOM nodes.- The old
JSON.parse(JSON.stringify(...))trick is worse: it turns dates into strings, silently dropsundefinedand functions, manglesNaN/Infinityintonull, throws onBigInt, and cannot handle Maps, Sets, or cycles. PreferstructuredClone. - For immutable nested updates, do NOT deep-clone the whole thing -- copy only along the path being changed with nested spread, sharing the unchanged branches. That is cheaper, pure, and the basis of real state management.
Next episode we go the other direction entirely -- from copying data to locking it. We will look at object immutability with freeze, seal, and preventExtensions: three ways to stop an object from being changed at all, and exactly how far each one actually goes.