The illusion of the shallow copy
When you copy an object with the spread ({ ...obj }) or with
Object.assign({}, obj), JavaScript copies only the first level. If some
property is itself an object or an array, what gets copied is the reference,
not its contents. The copy and the original share that nested object.
Think of a photocopy of a photo album: the photocopy has its own cover, but instead of copying the photos it sticks on a post-it that says "the photos are in the original album". If someone scratches out a photo, it shows up scratched in both.
const original = {
name: "Ann",
address: { city: "Madrid" },
};
const copy = { ...original };
copy.name = "Bea"; // affects ONLY the copy (first level)
copy.address.city = "Bilbao"; // it ALSO affects the original!
console.log(original.name); // "Ann" (good)
console.log(original.address.city); // "Bilbao" (bug!)
name is a string (primitive) and was truly copied. But address is an
object: both share it. Changing one changes the other.
Deep copy
A deep copy recursively clones every level, so that the copy is completely independent of the original. There are three common paths:
1. structuredClone (the modern way)
It is a native function of the browser and of modern Node that clones deeply:
const copy = structuredClone(original);
copy.address.city = "Bilbao";
console.log(original.address.city); // "Madrid" (untouched)
It supports objects, arrays, Map, Set, dates... but not functions.
2. JSON (for simple data)
Converting to text and back creates a deep copy in one line:
const copy = JSON.parse(JSON.stringify(original));
It is handy, but it only works for "JSON-pure" data: it loses functions,
undefined, dates (they become strings), Map/Set, etc.
3. Recursion by hand
Walk the structure and clone each level. It is what the two options above do internally:
function clone(value) {
if (Array.isArray(value)) return value.map(clone);
if (value && typeof value === "object") {
const output = {};
for (const key of Object.keys(value)) output[key] = clone(value[key]);
return output;
}
return value; // primitives: returned as-is
}
Freezing to prevent mutations: Object.freeze
If you want to guarantee that an object is not modified, Object.freeze
makes it read-only: any attempt to change it is ignored (or throws an error in
strict mode).
const config = Object.freeze({ theme: "dark" });
config.theme = "light"; // ignored
console.log(config.theme); // "dark"
Careful:
Object.freezeis also shallow. It freezes the first level, but nested objects stay mutable unless you freeze them yourself too (a recursive "deep freeze").
Examples
The shallow-copy bug
const original = { level: 1, data: { points: [10, 20] } };
const copy = { ...original };
copy.data.points.push(30); // mutates the SHARED array
console.log("original:", original.data.points); // [10, 20, 30] contaminated!
console.log("copy: ", copy.data.points); // [10, 20, 30]
Deep copy with structuredClone
const original = { level: 1, data: { points: [10, 20] } };
const copy = structuredClone(original);
copy.data.points.push(30);
console.log("original:", original.data.points); // [10, 20] untouched
console.log("copy: ", copy.data.points); // [10, 20, 30]
Object.freeze prevents mutation
const settings = Object.freeze({ volume: 5 });
settings.volume = 11; // silently ignored
console.log(settings.volume); // 5