12

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:

  1. It claims to perform deep cloning
  2. This behavior doesn't happen when using JSON.parse(JSON.stringify(value))
15
  • 11
    If you had done myObj.field2[1] = 'baz' instead, you would have the exact same behavior in myObj already. So why should a clone behave any different, than the original? Commented Jul 5, 2024 at 10:19
  • 6
    Does it also reflect to myObj? If yes: that'd be surprising. If not: arguably myObjCloned behaves exactly as myObj would. Commented Jul 5, 2024 at 10:19
  • 4
    Surprising but knowing that makes structuredClone even better Commented Jul 5, 2024 at 10:21
  • 8
    myObjCloned IS deep cloned. As is the array. The structure is preserved as-is and there are no links from someSharedArray to the clone. All the requirements for deep cloning are fulfilled. You seem to expect that the shared array would suddenly become multiple arrays but I don't think it's a reasonable expectation. Commented Jul 5, 2024 at 10:22
  • 7
    JSON.parse(JSON.stringify) has always been an ugly hack IMO, which has always had several asterisks attached to it, like not being able to "clone" certain types or causing shared array references to arguably break. structuredClone is the much more faithful cloning mechanism. You've just been relying on side effects of the JSON workaround so far. Commented Jul 5, 2024 at 10:24

3 Answers 3

17

not truly deep copy?

It is a deep copy.

A proper deep copy should adhere to a few conditions:

  1. 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.

  2. 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.

Another scenario

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.

Sign up to request clarification or add additional context in comments.

3 Comments

OP even mentioned this themselves: "structuredClone appears to be a superset [of this technique], while also supporting things like […] circular references."!
So for the last scenario, when we do the structuredClone of root to say newRoot, it will be the same exact same situation in newRoot (basically a copy and paste) but what about the values inside newRoot.arr, is that also array of 3 root or will it be newRoot?
@CyberMew, it should be cloned, so there should never be any already existing object reference (like root) in the result. In this example, newRoot = structuredClone(root) will make an array whose three elements are references to that new array itself, so newRoot[0] === newRoot will be true, just like the original had root[0] === root. But this is easy to verify yourself, no?
2
// Step 1: Create a shared array // --------------------------------------------- const someSharedArray = ['foo', 'bar']; // Suppose this array is stored at memory address 0x1 // Step 2: Create an object where every field // references the SAME array (0x1) // --------------------------------------------- const myObj = { field1: someSharedArray, // → 0x1 field2: someSharedArray, // → 0x1 field3: someSharedArray, // → 0x1 }; // The object itself (myObj) is at address 0x0 console.log('Original object (myObj):', myObj); /* myObj (0x0): field1 → 0x1 ['foo', 'bar'] field2 → 0x1 ['foo', 'bar'] field3 → 0x1 ['foo', 'bar'] */ // Step 3: Deep clone the object using structuredClone() // --------------------------------------------- const myObjCloned = structuredClone(myObj); // structuredClone() creates a deep copy of the structure. // It notices that field1, field2, and field3 all point to the same array (0x1). // Therefore, it creates ONE new array (at 0x3) and makes all fields // in the clone point to that same new array. console.log('After structuredClone():', myObjCloned); /* myObjCloned (0x2): field1 → 0x3 ['foo', 'bar'] field2 → 0x3 ['foo', 'bar'] field3 → 0x3 ['foo', 'bar'] Notes: - The cloned object itself is at address 0x2. - It has a new array (0x3), not the same as the original (0x1). - But within the clone, all fields share that same new array. */ // Step 4: Modify one field’s array in the clone // --------------------------------------------- myObjCloned.field2[1] = 'baz'; // This modifies the array stored at address 0x3. console.log('After modifying myObjCloned.field2:', myObjCloned); /* myObjCloned (0x2): field1 → 0x3 ['foo', 'baz'] (changed) field2 → 0x3 ['foo', 'baz'] (changed) field3 → 0x3 ['foo', 'baz'] (changed) */ // Step 5: Check if the original object was affected // --------------------------------------------- console.log('Original (myObj):', myObj); /* myObj (0x0): field1 → 0x1 ['foo', 'bar'] (unaffected) field2 → 0x1 ['foo', 'bar'] field3 → 0x1 ['foo', 'bar'] */ console.log('Are cloned and original arrays the same?', myObj.field1 === myObjCloned.field1); // false (0x1 ≠ 0x3) console.log('Are cloned fields internally the same?', myObjCloned.field1 === myObjCloned.field2); // true (both point to 0x3)

2 Comments

Hi, this looks like a modified copy of Marcos answer. Please also add an explanation, why you believe it's better, as suggested by How to Answer.
As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center.
1

I'm late for the party, but I wanted to say that this is more accurate than JSON.parse(SON.stringify(value)). If you check the following:

const someSharedArray = ['foo', 'bar'] const myObj = { field1: someSharedArray, field2: someSharedArray, field3: someSharedArray, } const myObjCloned = structuredClone(myObj) someSharedArray.push('baz') myObjCloned.field1.push('who') console.log({ myObjCloned , someSharedArray }) /** { "myObjCloned": { "field1": [ "foo", "bar", "who" ], "field2": [ "foo", "bar", "who" ], "field3": [ "foo", "bar", "who" ] }, "someSharedArray": [ "foo", "bar", "baz" ] } **/

This shows that the new myObjCloned is indeed cloned, creating new object references, but makes it more convenient since it's keeping an equivalent structure of its internal pointer references.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.