Object Cloning in JavaScript (Deep vs. Shallow Copies) - A Comprehensive Guide

Object Cloning in JavaScript (Deep vs. Shallow Copies) - A Comprehensive Guide

Introduction

In the world of JavaScript, understanding how data is copied and referenced is crucial for preventing bugs and ensuring the correct behavior of applications. One of the most common sources of confusion is the difference between deep and shallow copies.

What are the differences between a deep copy and a shallow copy in JavaScript? Let us find out in this article.

Bonus: At the end of this article, I will also share my recommendations based on different factors and situations.

Prerequisites

  • Basic JavaScript Knowledge

  • Familiarity with Objects and Arrays: An understanding of how objects and arrays work, including how to create, access, and modify their properties and elements.


Understanding Reference Values

If you know anything about working with primitive data types (Strings, Numbers, Booleans, etc.), your default response to cloning data is assigning it to a new variable. With primitive data types, this is the end of the story, as a brand new independent value gets created and stored in memory, with no ties to the original. Changes to this value do NOT affect the original, and vice versa.

That, unfortunately, is not the case with composite data types (Objects and Arrays). Let us compare their different behaviors:

Primitive Types

Take a look at the example below:

// Numbers
let num1 = 7
let num2 = num1

console.log(num2)    // 7

num1 = 10

console.log(num1)    // 10
console.log(num2)    // 7

We assigned 7 to the variable num1, assigned the value of num1 (i.e. 7) to the variable num2, then changed the value of num1 to 10. When we log both variables, we observe that num1 has been updated to 10, while num2 is still 7. This is the default behavior of primitive types.

Next, let us create a function that modifies a primitive type:

// Strings
function modifyString(input) {
    input += " --modified"
    return input
}

let student1 = "Akin"
let student2 = modifyString(student1)

console.log(student1)    // "Akin"
console.log(student2)    // "Akin --modified"

Here, we've introduced a new function: modifyString. This function takes a string as its parameter and returns a modified version of that string, marked with the "--modified" flag. Initially, we assigned the value "Akin" to student1. Then, we passed student1 to the modifyString function and assigned the result to student2. The outcome is as follows: student1 remains unchanged as "Akin", while student2 receives the "--modified" flag.

This is also consistent with the behavior of primitive types.

Reference Types

let num1 = [1, 2, 3, 4, 5, 6]
let num2 = num1

console.log(num2)    // [1, 2, 3, 4, 5, 6]

num1[2] = 10

console.log(num1)    // [1, 2, 10, 4, 5, 6]
console.log(num2)    // [1, 2, 10, 4, 5, 6]

Notice how when we change the third element of num1 from 3 to 10, it reflects on num2 also. This is unlike the behavior of the primitive types we have considered so far.

Next, let us create a function that modifies a reference type:

function modifyArray(input) {
    input.push(99)
    return input
}

let numList = [1, 2, 3, 4, 5, 6]
let numList_2 = modifyArray(numList)

console.log(numList) // [1, 2, 3, 4, 5, 6, 99]
console.log(numList_2) // [1, 2, 3, 4, 5, 6, 99]

Here, we've introduced a new function: modifyArray. This function takes an array as its parameter and returns a modified version of that array, with the element 99 added to the end. Initially, we assigned the array [1, 2, 3, 4, 5, 6] to numList. Then, we passed numList to the modifyArray function and assigned the result to numList_2. The outcome is as follows: both numList and numList_2 get updated to [1, 2, 3, 4, 5, 6, 99].

You will encounter this behavior with arrays and objects in JavaScript. That is the default behavior in JavaScript, and it happens because arrays, functions, and objects are composite data types, which are also sometimes referred to as reference types. That means that when you reassign a reference type to a new variable, you are simply copying a reference to the same object in memory and not the object itself.

What if that is not what you want? What if the functionality of your program relies on the creation of unique and independent clones? Let us discuss your options.


Shallow Copies

Let us begin by understanding shallow copies. When you shallow copy an array or object in JavaScript, you create a new array or object, such that only the top-level structure is duplicated. All nested properties are still shared between the original and the copy.

Important definitions:

  • Top-level Element in Arrays*: An element that directly belongs to the array and is not nested within any other arrays or objects within it. e.g. elements* 1, 2, 3, and 7 in the array [1, 2, 3, [4, 5, 6], 7]

  • Top-level Element in Objects*: A property-value pair in an object that is directly defined within the object itself and is not nested within any other objects. e.g. properties* a and b in the object {a: 1, b: 2, c: {x: 1, y: 2} }

Creating Shallow Copies

In JavaScript, there are three major means of creating shallow copies:

  1. Spread operator (...)

  2. Object.assign()

  3. Array.from()

What's all the talk without examples?

  1. Spread Operator (...)

     const teamArr = ["John", "Will", ["Ali", "Angie", "Chike"]]
     const shallowTeamArr = [...teamArr]
     console.log(shallowTeamArr)    // ["John", "Will", ["Ali", "Angie", "Chike"]]
    
     teamArr[1] = "Ben"
     teamArr[2][0] = "Kalu"
    
     console.log(teamArr)           // ["John", "Ben", ["Kalu", "Angie", "Chike"]]
     console.log(shallowTeamArr)    // ["John", "Will", ["Kalu", "Angie", "Chike"]]
    

    In this example, I'm only updating the original array (teamArr), and, of course, changes at the top level work as expected. Only the name in teamArr is changed (from "Will" to "Ben"). Its equivalent in shallowTeamArr remains the same.

    Now, let's take a look at the nested array. When I swap out "Ali" for "Kalu" in teamArr, the change is reflected in shallowTeamArr. This behavior sums up shallow copies in a nutshell.

    This behavior is also observed with objects, whether we use the spread operator (...) or JavaScript's Object.assign method. This is illustrated in the following examples:

     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
    
     const shallowUserObj = {...userObj}
     console.log(shallowUserObj)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     userObj.name = "Charlie"
     userObj.car.model = "RS6"
    
     console.log(userObj)
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    
     console.log(shallowUserObj)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "RS6" }
     }    */
    
  2. Object.assign()

     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
    
     const shallowUserObj = Object.assign({}, userObj)
     console.log(shallowUserObj)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     userObj.name = "Charlie"
     userObj.car.model = "RS6"
    
     console.log(userObj)
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    
     console.log(shallowUserObj)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "RS6" }
     }    */
    
  3. Array.from()

     const teamArr = ["John", "Will", ["Ali", "Angie", "Chike"]]
     const duplicatedTeamArr = Array.from(teamArr)
     console.log(duplicatedTeamArr)    // logs ["John", "Will", ["Ali", "Angie", "Chike"]]
    
     teamArr[1] = "Ben"
     teamArr[2][0] = "Kalu"
    
     console.log(teamArr)    // logs ["John", "Ben", ["Kalu", "Angie", "Chike"]]
     console.log(duplicatedTeamArr)    // logs ["John", "Will", ["Kalu", "Angie", "Chike"]]
    

I'm sure you can already picture more than a few scenarios where this would be undesirable. So, how do we "fix" this behavior? Deep copies.


Deep Copies

When you perform a deep copy of an array or object in JavaScript, you create a new array or object in which every level of the object or array structure is duplicated*.* All properties are replicated and have no connection to the original, aside from containing the same values. You can envision this copy as a wholly independent version of the object with no shared properties, whether they are nested or not.

Creating Deep copies

When it comes to implementing deep copies from scratch, the recursive approach stands out as our preferred tool of choice.

Here are a few methods:

  1. Recursive approach

  2. JSON.parse() + JSON.stringify()

  3. structuredClone()

  4. Lodash's cloneDeep()

Let us try out some examples:

  1. Recursive approach

     function deepCopy(obj) {
       if (typeof obj !== 'object' || obj === null) {
         return obj;
       }
    
       if (Array.isArray(obj)) {
         return obj.map(deepCopy);
       }
    
       const newObj = {};
       for (const key in obj) {
         newObj[key] = deepCopy(obj[key]);
       }
    
       return newObj;
     }
    
     /* ----------------- Examples ----------------- */
     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
     const deepCopiedUserObject = deepCopy(userObj)
    
     console.log(deepCopiedUserObject)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     deepCopiedUserObject.name = "Charlie"
     deepCopiedUserObject.car.model = "RS6"
    
     console.log(userObj);
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     console.log(deepCopiedUserObject); 
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    

    All done! Notice how only deepCopiedUserObject is updated in this example? His name changes to "Charlie," and his car model changes to the "RS6", all while leaving userObj untouched and free from interference. This is the desired outcome – deepCopiedUserObject is an independent clone.

    I could conclude this article here, but let us explore other options to better inform our decisions.

  2. JSON.parse() + JSON.stringify()

     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
     const jsonString = JSON.stringify(userObj)
     const deepCopiedUserObject = JSON.parse(jsonString)
     console.log(deepCopiedUserObject)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     deepCopiedUserObject.name = "Charlie"
     deepCopiedUserObject.car.model = "RS6"
    
     console.log(userObj);
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     console.log(deepCopiedUserObject); 
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    

    Works just as well, or does it? This method utilizes the JSON.parse() and JSON.stringify() methods available on the built-in JSON namespace to serialize and deserialize objects to and from JSON format. The underlying concept involves converting our object into a string (a non-reference data type), then transforming it back into an object and assigning it to a new variable.

    WARNING: This method is essentially a hack and is a less-than-perfect solution known to have several shortcomings, including:

    • Data Loss: JSON.stringify() cannot serialize certain JavaScript data types, such as functions, undefined values, null, Date(), and objects with cyclic references. When such data types are encountered, they are either omitted or converted to null in the JSON string, leading to data loss.

    • Loss of functionality

    • Performance issues: cloning with JSON.parse(JSON.stringify()) can get very slow, especially as the object gets larger.

  3. structuredClone()

     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
     const deepCopiedUserObject = structuredClone(userObj)
     console.log(deepCopiedUserObject)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     deepCopiedUserObject.name = "Charlie"
     deepCopiedUserObject.car.model = "RS6"
    
     console.log(userObj);
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
     console.log(deepCopiedUserObject); 
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    

    This does the job perfectly! The structuredClone() method is part of the HTML Living Standard (HTML5) and allows you to create clones of structured data, including complex objects and cyclic structures. structuredClone() is designed to handle complex objects, including DOM elements and custom classes, and creates deep copies by default.

    It has the added advantage of being a built-in JavaScript API that is present in all modern browsers.

  4. Lodash's cloneDeep()

     const _ = require("lodash")
    
     /* ----------------- Examples ----------------- */
     const userObj = {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }
     const deepCopiedUserObject = _.cloneDeep(userObj)
     console.log(deepCopiedUserObject)
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     deepCopiedUserObject.name = "Charlie"
     deepCopiedUserObject.car.model = "RS6"
    
     console.log(userObj);
     /*
     {
       name: "John",
       car: { maker: "Audi", model: "R8" }
     }    */
    
     console.log(deepCopiedUserObject); 
     /*
     {
       name: "Charlie",
       car: { maker: "Audi", model: "RS6" }
     }    */
    

    While not a built-in JavaScript API, the Lodash library provides a cloneDeep() function that creates deep copies of objects and arrays. It is optimized to handle even the most complex data structures reliably and efficiently.

My recommendation

You will encounter situations where a shallow copy is sufficient. When you do, stick with the spread operator (...). When you find you need a deep copy though, structuredClone() is your best bet. If your project already has Lodash though, you might want to pull out the cloneDeep() method, as it is even more performant than the already efficient structuredClone().

Wrapping up

In conclusion, understanding the nuances of deep and shallow copying in JavaScript is a pivotal skill for any developer. When you find yourself needing to preserve the integrity of unique object states or history, deep copying is your ally. For instance, consider a version control system or undo functionality in an application, where deep copying ensures each version remains distinct.

On the other hand, when optimizing performance and memory is paramount or when maintaining relationships between large data structures is necessary, shallow copying shines. Imagine a complex hierarchical menu system or a state management system in a frontend framework, where shallow copying maintains references and ensures efficient updates.

By mastering both techniques, you empower yourself to choose the right tool for the job, making your code more efficient, maintainable, and resilient.

Happy hacking!