Before you begin
Why this matters
A copy that looks independent can still share an array deep inside it, and a change made there shows up in the original. Knowing which levels a copy duplicated lets you predict that change, and copy just enough.
What this helps you reason about
- Say which levels a spread, slice(), or structuredClone duplicates.
- Write a deep copy by hand, one level at a time.
- Choose which levels to copy and which to keep shared.
A quilt pattern holds rows, and each row holds patch colours. A copy of the pattern can duplicate the pattern object alone, the list of rows too, or every row as well. This lesson follows a copy level by level.
Already know this? The short version
- A nested value has levels: the outer object, the arrays stored in it, and the values stored in those; each level holds references to the next.
- A spread or slice() duplicates one level; every object below it is still shared, which === shows.
- A deep copy duplicates every level that can change: structuredClone for plain data, or a function that copies each level in a loop.
- structuredClone turns a class instance into a plain object without methods, and throws for a function.
- Copy the levels that some code will change; keep shared, on purpose, what must stay one object.
Words in this lesson (5)
- level
- One layer of a nested value. The outer value is level 1, the values stored directly inside it are level 2, the values stored inside those are level 3, and so on.
- reference
- A value that leads to an object. A variable whose value is an object holds a reference, not a copy of the object, so copying the variable gives a second way to reach the same object.
- shallow copy
- A copy of the outer value only. Objects, lists, or pointers stored inside it are copied as references, so the copy shares them with the original.
- deep copy
- A copy of a value and of everything stored inside it, at every level, so that it shares nothing that can change with the original.
- structuredClone
- A built-in function that makes a deep copy of plain data: arrays, plain objects, Maps, Sets, and Dates. A class instance comes back as a plain object without its methods, and a function cannot be copied.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Levels
What are the levels of a nested value?
Copying, identity, and aliasing showed that a shallow copy shares the objects inside it. This lesson follows a copy through every level of a nested value.
Picture a nested value as levels. Below, the pattern object is level 1. Its rows array is level 2: an object stored inside level 1. Each row is level 3: an array stored inside the rows array. The colour strings sit at the bottom; a string cannot be changed, so it never needs a copy.
Each level holds references to the level below it, not copies. So the question “what does this copy share?” becomes one question per level: which levels did the copy make new, and which does it still share?
// level 1: the pattern object
const pattern = {
name: "Dawn",
// level 2: the rows array
rows: [
// level 3: each row
["red", "blue"],
["gold", "green"],
],
}; A nested value has levels, and each level holds references to the level below.
02 / Shallow
What does a shallow copy duplicate?
A shallow copy duplicates level 1 only. { ...pattern } makes a new pattern object and copies each property the way an assignment copies it. name gets the same string, and rows gets the same reference. So copy.rows and pattern.rows are one array, and the rows inside it are shared too.
Copying one more level does not make the copy independent. In { ...pattern, rows: pattern.rows.slice() }, the spread copies every property, then rows is replaced by the value after the comma: a new rows array. Replacing a whole row in the copy now leaves the pattern alone. But slice() is shallow as well: the new rows array holds the same row arrays, so changing a colour inside a row still reaches the pattern.
=== shows which level is shared. copy.rows === pattern.rows asks about level 2, and copy.rows[1] === pattern.rows[1] asks about level 3.
const copy = { ...pattern, rows: pattern.rows.slice() };
// level 2 is new: the pattern keeps its first row
copy.rows[0] = ["tan"];
// level 3 is shared: pattern.rows[1][0] is "pink" too
copy.rows[1][0] = "pink";
console.log(copy.rows === pattern.rows); // false
console.log(copy.rows[1] === pattern.rows[1]); // true Each shallow copy duplicates one level; everything below it is still shared.
Experiment
Copy a pattern one level, then two
The spread makes a new object and copies each property: name gets the same string, and rows gets the same reference. slice() makes a new rows array that holds the same row arrays. Each copy duplicates one level, and everything below it stays shared.
Why it matters Count the levels a copy makes new. A change below the last copied level reaches the original.
Predict both printed lines. Then change const copy = { ...pattern }; to const copy = { ...pattern, rows: pattern.rows.slice() }; and predict again: which line changes?
const pattern = {
name: "Dawn",
rows: [["red", "blue"], ["gold", "green"]],
};
const copy = { ...pattern };
copy.name = "Dusk";
copy.rows[1][0] = "pink";
const sameRows = copy.rows === pattern.rows;
const sameRow1 = copy.rows[1] === pattern.rows[1];
console.log(pattern.name, pattern.rows[1][0]);
console.log(sameRows, sameRow1);
What to look for
Step past copy.name = "Dusk": copy shows name "Dusk" and pattern keeps "Dawn". The panel shows each row as Array(2): it lists one level of an array inside an object, so sameRows and sameRow1 are the evidence. Both show true, and the run prints Dawn pink, then true true. After the edit, sameRows shows false and sameRow1 true, and the run prints Dawn pink, then false true: the rows array is new, but row 1 is still shared. A let or const that has not run yet shows as not initialized yet, and Step can stop on one line twice; both are normal for this debugger.
Expected output
Dawn pink
true true03 / Deep
How do you write a deep copy?
A deep copy duplicates every level. structuredClone(pattern) does this for plain data: it builds a new object, a new rows array, and a new array for each row.
You can also write the copy yourself, one level at a time. copyPattern below makes a new rows array (level 2), fills it with a slice() of each row (level 3), and returns a new object (level 1). for (const row of p.rows) runs its block once for each row, and push adds an element at the end of an array. The colour strings are not copied: no code can change them.
A hand-written copy lists every level, so you can see what it copies. It is also only as deep as its author made it: a property added to Pattern later needs a line in copyPattern too.
type Pattern = { name: string; rows: string[][] };
function copyPattern(p: Pattern): Pattern {
const rows: string[][] = [];
for (const row of p.rows) {
rows.push(row.slice());
}
return { name: p.name, rows: rows };
} A deep copy copies each level in turn, down to values that cannot change.
Coming from Python: copy.copy(x) is a spread or slice(); copy.deepcopy(x) is closest to structuredClone(x), but deepcopy also copies class instances, and structuredClone does not keep their class.
Precisely: the JSON round trip
JSON.parse(JSON.stringify(value)) also copies every level, but only what JSON can hold: a Map becomes {}, a Date becomes a string, and a property whose value is undefined disappears. structuredClone keeps all three.
Experiment
Copy every row in a deep copy function
copyPattern makes a new array for level 2 and a slice() of each row for level 3, and returns a new object for level 1. No level of dusk that can change is shared with dawn. With rows.push(row), level 3 is shared again.
Why it matters A deep copy written by hand is as deep as its loops. Check that every level that can change gets a new copy.
Predict both printed lines. Then change rows.push(row.slice()); to rows.push(row); and predict again.
type Pattern = { name: string; rows: string[][] };
function copyPattern(p: Pattern): Pattern {
const rows: string[][] = [];
for (const row of p.rows) {
rows.push(row.slice());
}
return { name: p.name, rows: rows };
}
const dawn: Pattern = {
name: "Dawn",
rows: [["red", "blue"], ["gold", "green"]],
};
const dusk = copyPattern(dawn);
dusk.rows[1][0] = "pink";
console.log(dawn.rows[1][0], dusk.rows[1][0]);
console.log(dusk.rows[1] === dawn.rows[1]);
What to look for
Step into copyPattern. At each pass, row shows one row of the pattern, such as ["red", "blue"], and rows grows by one copied row. Back at the top level, dusk shows the same contents as dawn. The run prints gold pink, then false. After the edit, the run prints pink pink, then true: rows is a new array, but it holds the pattern’s own rows.
Expected output
gold pink
false04 / Limits
Where does a built-in copy stop?
A class describes a kind of object: its properties, a constructor that runs when new Row(…) creates an object, and methods, functions that every such object has, called as row.count(). Inside them, this is the object itself.
structuredClone copies data, not behavior. It copies arrays, plain objects, Maps, Sets, and Dates. A class instance comes back as a plain object with the same properties, but without the class’s methods. A function cannot be copied at all: structuredClone throws DataCloneError.
TypeScript does not warn about this. structuredClone(rows) has the same type as rows, so the compiler accepts a call to a method that the copy does not have, and the run then stops with a TypeError. x instanceof Row is true when x was made by new Row(…), so it tells a class instance from a plain object.
For a class, write the copy inside the class: a method that calls new with copies of the fields.
class Row {
colours: string[];
constructor(colours: string[]) {
this.colours = colours;
}
copy(): Row {
return new Row(this.colours.slice());
}
} structuredClone copies plain data: a class instance loses its methods, and a function cannot be copied.
Coming from Java: There is no clone method that every object inherits. A class gets a copy only if you write one, such as copy() above.
Experiment
Clone a class instance with structuredClone
structuredClone copied the data: a new array, a new object, and a new colours array. It did not keep the class, so the copy has no count method. TypeScript still gives copy the type Row[], so the call compiles.
Why it matters Use structuredClone for plain data. For class instances, write a copy method that builds a new instance with copies of its fields.
Predict both printed lines. Then change console.log(typeof copy[0].count); to console.log(copy[0].count()); and predict what happens.
class Row {
colours: string[];
constructor(colours: string[]) {
this.colours = colours;
}
count(): number {
return this.colours.length;
}
}
const rows = [new Row(["red", "blue"])];
const copy = structuredClone(rows);
console.log(copy[0].colours[0], copy[0] instanceof Row);
console.log(typeof copy[0].count);
What to look for
Step past new Row(…): the stepper enters the constructor, and for one stop a frame named <instance_members_initializer> appears; it is how Node runs a class’s field setup. In the constructor, this is listed first: {colours: undefined}, then {colours: ["red", "blue"]}. Back at the top level, rows and copy both show [{colours: Array(2)}]: the panel does not show the class, so instanceof is the evidence. The run prints red false, then undefined. After the edit, the run prints red false, then stops with TypeError: copy[0].count is not a function, at line 14.
Expected output
red false
undefined05 / Choosing
How deep should a copy go?
Copy the levels that some code will change, and share the rest. If code only replaces whole rows, a new rows array is enough. If code changes colours inside a row, each row needs its own copy too.
Some parts must stay shared. Suppose every pattern points to the circle member who designed it, an object with a name. When a member changes their display name, every pattern they designed should show it. A copy that also copies the designer gives the copy a member of its own. That copy never sees the change, and copy.designer === pattern.designer is false although both name one person. It also uses memory for nothing.
So a copy is a decision per level: copy what the copy will change on its own, and share what must stay one object.
type Member = { display: string };
type Pattern = { rows: string[][]; designer: Member };
function copyPattern(p: Pattern): Pattern {
const rows: string[][] = [];
for (const row of p.rows) {
rows.push(row.slice());
}
// One designer for every copy, on purpose.
return { rows: rows, designer: p.designer };
} Copy what the copy will change; share, on purpose, what must stay one object.
06 / Try it
Can you explain a fresh example?
Decide for each line which level it changes, and whether that level is shared. The next lesson, Stack and heap, shows where values are stored while a program runs. Its link is at the end of the page.
const kit = { name: "Star", rows: [["red"], ["blue"]] };
const a = { ...kit, rows: kit.rows.slice() };
a.rows[0][0] = "pink";
a.rows[1] = ["gold"];
console.log(kit.rows[0][0], kit.rows[1][0]);
What is printed?
Reveal the reasoning
pink blue. a is a new object (level 1) with a new rows array (level 2), but the rows inside it are kit’s rows (level 3). a.rows[0][0] = "pink" writes into a shared row, so kit sees it. a.rows[1] = ["gold"] replaces an element of a’s own rows array, so kit keeps ["blue"].
Practice tickets
- DEEP-101 · Editing one square of a new chart changes other squares → Free
- DEEP-102 · Copied projects keep showing old paint places → Sign in · Pro
- DEEP-103 · Progress on a long book shows at only one meeting → Sign in · Pro