Today, while investigating a bug in our app, I witnessed a very surprising behavior in JavaScript's structuredClone
.
This method promises to create a deep clone of a given value. This was previously achieved using the JSON.parse(JSON.stringify(value))
technique, and on paper structuredClone
appears to be a superset per se of this technique, yielding the same result, while also supporting things like Dates and circular references.
However, today I learned that if you use structuredClone
to clone an object containing reference type variables pointing to the same reference, these references will be kept, as opposed to creating new values with different references.
Here is a toy example to demonstrate this behavior:
const someSharedArray = ['foo', 'bar']
const myObj = {
field1: someSharedArray,
field2: someSharedArray,
field3: someSharedArray,
}
const myObjCloned = structuredClone(myObj)
console.log(myObjCloned)
/**
{
"field1": ["foo", "bar"],
"field2": ["foo", "bar"],
"field3": ["foo", "bar"],
}
**/
myObjCloned.field2[1] = 'baz'
// At this point:
// Expected: only `field2`'s value should change, because `myObjCloned` was deeply cloned.
// Actual: all fields' values change, because they all still point to `someSharedArray`
console.log(myObjCloned)
/**
{
"field1": ["foo", "baz"],
"field2": ["foo", "baz"],
"field3": ["foo", "baz"],
}
**/
This is a very surprising behavior of structuredClone
, because:
JSON.parse(JSON.stringify(value))
not truly deep copy?
It is a deep copy.
A proper deep copy should adhere to a few conditions:
It should map every distinct object in the original to exactly one distinct object in the result: a 1-to-1 mapping. This is also the guiding principle that ensures that circular references are supported.
If two distinct properties have identical values (Object.is(a, b) === true
), then these properties in the deep clone should also be identical to each other.
In your example input there are two distinct objects: one array, and one (top-level) complex object. Furthermore, the result of Object.is(myObj.field1, myObj.field2)
is true.
What you get with structuredClone
in your example adheres to this. Notibly, Object.is(myObjCloned.field1, myObjCloned.field2)
is true.
What you expected to get (and what JSON.parse(JSON.stringify(value))
returns) violates this principle: three distinct arrays would be created, which means the same array has been copied more than once, and there is no 1-to-1 mapping anymore. The previously mentioned Object.is
expression evaluates to false.
Let's take an input with a back reference:
const root = {};
root.arr = [root, root, root];
Here we have one object and one array. That latter holds three references to the first object. Also here we expect these three references to one object to result in another trio of references, each referencing the one-and-only clone-parent object. This is the same principle as what happens in your example, just that the shared reference happens to be a parent object.