cf.completefrontend

Write the codeHard

Write deepClone(value) that copies nested data and handles cycles, Map, Set, and Date.

Before you start

What's the difference between a shallow and a deep copy?

Copying an object means making a new object with the same properties. A shallow copy makes one new object and copies each property's value as it is. When that value is itself an object or an array, what gets copied is a reference to it, so the copy and the original share the nested object. A deep copy also copies the nested objects, and the objects inside those, all the way down, so it shares nothing with the original.

Picture an address book. A shallow copy is a new book with the same addresses: paint a house you found through the copy, and it's painted for anyone using the original book too. A deep copy would build a new house for every address.

const original = { name: "Ada", address: { city: "London" } };
const copy = { ...original }; // a shallow copy
 
copy.name = "Grace";         // changes only the copy
copy.address.city = "Paris"; // changes the one address both share
original.name;               // "Ada"
original.address.city;       // "Paris"
copy.address === original.address; // true
 
const deep = structuredClone(original); // a deep copy
deep.address.city = "Rome";
original.address.city;       // still "Paris"
CodeCopyNested objects
{ ...obj }, Object.assign({}, obj)ShallowShared with the original
[...arr], arr.slice(), Array.from(arr)ShallowShared with the original
structuredClone(value)DeepCopied
JSON.parse(JSON.stringify(value))Deep, of what JSON can holdCopied, but dates become strings, properties holding undefined or a function are dropped, and a cycle throws

structuredClone is built into browsers and Node. It copies dates, Maps, and Sets, and keeps cycles, but it throws on functions and DOM nodes, and a class instance comes back as a plain object without its methods.

The difference matters most with state in React and Redux, which you update by making copies. A shallow copy is a new object, but changing a nested object inside it still changes the old state.

What's a circular reference?

A circular reference is a chain of properties that leads from an object back to itself. It can be direct, as in obj.self = obj, or pass through other objects, as when a team points to its lead and the lead points back to the team. There's nothing wrong with such data, and you can use it normally. The trouble is code that follows every property into every nested object: it goes round the loop and never reaches the end.

const team = { name: "Frontend" };
const ada = { name: "Ada", team };
team.lead = ada; // team -> ada -> team -> ...
 
ada.team.lead.team.lead.name; // "Ada": you can go round as often as you like
JSON.stringify(team);         // throws a TypeError

Here's a walk that visits every nested object, starting from team:

StepThe walk is atIt followsWhich leads to
1teamteam.leadada
2adaada.teamteam, where it started
3teamteam.leadada again, and so on forever

JSON.stringify detects the loop and throws a TypeError, since the text would never end. A function you write that copies, compares, or freezes nested objects, calling itself for each one, doesn't stop on its own: it keeps calling itself until the call stack runs out and the engine throws. So code that walks nested objects has to notice when it comes back round to an object it has already reached.

Reaching an object twice doesn't always mean a loop, though. A shared reference is one object reached by two different paths, with no way back to where you started:

const office = { city: "London" };
const staff = { ada: { office }, grace: { office } };
 
staff.ada.office === staff.grace.office; // true: one object, two paths
JSON.stringify(staff);
// '{"ada":{"office":{"city":"London"}},"grace":{"office":{"city":"London"}}}'
 
const copy = structuredClone(staff); // the built-in deep copy
copy.ada.office === copy.grace.office;   // true: one new office, still shared
copy.ada.office === office;              // false

JSON.stringify writes a shared object out once per path, so parsing the text back gives two separate offices. structuredClone keeps the sharing, and it keeps cycles too: copying team gives a new team whose lead points back to the new team.

Cycles are common in real data. A DOM element's parentNode has the element among its children, tree nodes often link to their parent, a doubly linked list points both ways, and globalThis.globalThis is globalThis.

What's a hole in an array?

An array's length counts slots, and a slot can be empty: no element at all, not even undefined. That empty slot is called a hole, and an array with holes is called sparse. Reading a hole gives undefined, the value you get for any property that doesn't exist, so it looks like an element holding undefined. Object.keys, which lists the indexes an array really has, tells the two apart:

const withHole = [1, , 3]; // nothing between the commas
const withUndefined = [1, undefined, 3];
 
withHole.length;      // 3
withUndefined.length; // 3
withHole[1];          // undefined
withUndefined[1];     // undefined
Object.keys(withHole);      // ["0", "2"]: there's no element at index 1
Object.keys(withUndefined); // ["0", "1", "2"]: there is one, and its value is undefined

Holes appear in a few ways:

HowExampleResult
A gap in an array literal[1, , 3]A hole at index 1
new Array with one numbernew Array(3)Three holes, and length is 3
delete on an elementdelete arr[1]Index 1 becomes a hole, and length doesn't change
Writing past the endarr[5] = 6 on [1, 2, 3]Holes at indexes 3 and 4, and length is 6
Making length biggerarr.length = 5 on [1, 2, 3]Holes at indexes 3 and 4

Consoles mark holes so you can spot them: Chrome shows [1, , 3] as [1, empty, 3], and Node as [ 1, <1 empty item>, 3 ].

Built-in methods don't agree on what to do with a hole: some skip it, some keep it in their result, and some read it as undefined, or write null in JSON. That's why every task that rebuilds one of them says exactly what to do with holes. Here's [1, , 3] going through a few:

CallResultWhat happened to the hole
[1, , 3].filter(() => true)[1, 3]Skipped: the callback isn't called for it, and the result has no hole
[1, , 3].map((n) => n * 10)[10, , 30]Skipped, but kept: the result has a hole in the same place
[1, , 3].reduce((sum, n) => sum + n)4Skipped: 1 starts the total, and the callback runs once, to add 3
[1, , 3].flat()[1, 3]Dropped
Array.from([1, , 3])[1, undefined, 3]Read as undefined, which becomes a real element
JSON.stringify([1, , 3])"[1,null,3]"Written as null

Return primitives and functions as they are.

Copy arrays, plain objects, class instances, Map, Set, Date, and RegExp deeply, including Map keys and Set values. Each copy keeps the original's prototype.

An object that two places share in the original is copied once, and both places share that copy. A cycle becomes the same cycle in the copy.

Keep holes in arrays, and copy every own enumerable property, including symbol keys and an own __proto__ key like the one JSON.parse can create.

Write this
function deepClone(value) {
  // your code
}
 
// const original = { when: new Date(0), tags: new Set(["a"]) };
// original.self = original;
// const copy = deepClone(original);
// copy.self === copy;          // true: the cycle now runs through the copy
// copy.tags !== original.tags; // true
// copy.tags.has("a");          // true
// copy.when instanceof Date;   // true
// deepClone([1, , 3]);         // [1, , 3]

Solve it in the editor, with tests

The answer

Write it yourself first. Then compare it with a reference solution and the checks it passes.

Checking your sign-in…