Deep copy vs shallow copy vs assignment operator

Last update: 06 June, 2020
Table of contents

In this post, we’ll try to understand the differences between deep object copy, shallow copy, and the assignment operator in JavaScript. To do that, we’ll have to talk about variables and values first.

Values in JavaScript

In this section, I will briefly talk about how I view the variables and values in JavaScript. There’s a lot to unpack, so if you want more details, check the resources below.

Variables point to values. Variables don’t point to other variables, and they don’t contain values. You can change where a variable points with the assignment operator (=) unless you declared it with the const keyword. In this case, you’ll get a syntax error.

In JavaScript, we have two kinds of values. The primitive values and the objects (some people may use the term reference value for object values). The primitive values can’t change (they are immutable) but the object values can (they are mutable). The primitive values include the string, number, bigint, boolean, null, undefined, and symbol types. Objects are a value type on their own. Inside the objects, you can find the arrays and the functions.

Resources

Assignment operator

Let’s now use the assignment operator to “copy” a value from a variable to another variable. Or, because I claimed that variables don’t contain values, let’s use the assignment operator to point both variables to the same value.

Primitives

Create a variable with the name message and point it to the string value "hello".

let message = "hello";

Create a variable with the name messageCopy. Point it to the same value the variable with the name message currently points to.

let message = "hello";
let messageCopy = message;

At this point, both variables point to the same string value "hello". If you log the variables in the console, you’ll see the "hello" string value two times:

let message = "hello";
let messageCopy = message;
console.log(message, messageCopy);
// Prints "hello", "hello".

Because primitive values are immutable, if you try to change the first letter of the "hello world" string, nothing happens. It fails silently or throws a TypeError in strict mode.

let message = "hello";
let messageCopy = message;
messageCopy[0] = "H";
console.log(messageCopy);
// Prints "hello", not "Hello".

However, you can change where the messageCopy variable points to. The highlighted line points the messageCopy variable to the “hello world” string value:

let message = "hello";
let messageCopy = message;
messageCopy = "hello world";

The variable with the name message still points to the “hello” value, so if you log it in the console, the result will be “hello”.

let message = "hello";
let messageCopy = message;
messageCopy = "hello world";
console.log(message);
// Prints "hello".

Objects (arrays)

We have a similar situation with array values. To remind you, JavaScript considers array values as object type values. You can point two variables to the same array value:

let numbers = [1, 2, 3];
let numbersCopy = numbers;
console.log(numbers, numbersCopy);
// Prints [1, 2, 3], [1, 2, 3]

You can change where the second variable points, without affecting the first (the same is true for primitive values):

let numbers = [1, 2, 3];
let numbersCopy = numbers;
numbersCopy = [4, 2, 3];
console.log(numbers, numbersCopy);
// Prints: [1, 2, 3], [4, 2, 3]

But because array values are mutable, you can change any element of the array value. In other words, you can mutate the array value, something you can’t do with primitive values like strings. In the following example, both variables point to the same array value, so both variables will see the change:

let numbers = [1, 2, 3];
let numbersCopy = numbers;
numbersCopy[0] = 4;
console.log(numbers, numbersCopy);
// Prints: [4, 2, 3], [4, 2, 3]

As a result, the assignment operator is not a good choice to “copy” an object value from one variable to another variable. If you mutate one value, both variables will see the change.

Shallow copy

Now, let’s see what happens if you try to make a shallow copy of an object. By shallow-copying an object, you create a new object value that has the same properties as the old object. Each property of the new object value points to the same values the old object’s properties are pointing. You’ll see what that means in a bit.

There are many ways you can shallow copy an object:

Let’s choose Object.assign for this example because it doesn’t require an external library:

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = Object.assign({}, cat);

The catCopy variable points to a mutable object value. That means you can change that object value if you want. Lets start by changing its likes property:

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = Object.assign({}, cat);

catCopy.likes = "food";

Nothing is stopping you from changing its object property:

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = Object.assign({}, cat);

catCopy.likes = "food";
cat.favoriteHuman.name = "John";

Let’s now log in the console the original object to see what’s going on:

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = Object.assign({}, cat);

catCopy.likes = "food";
cat.favoriteHuman.name = "John";
console.log(cat);
// Prints:
// {
//   "likes": "cuddles",
//   "favoriteHuman": {
//     "name": "John"
//   }
// }

You see that the likes property remains intact, but the favoriteHuman property changed. This is probably something you didn’t intend to do. This will start to make sense if we see what the shallow copy does. First, it creates a new object value:

let catCopy = {
  /* ...*/
};

Then, it creates the properties the original object had:

let catCopy = {
  likes: "TODO",
  favoriteHuman: "TODO",
};

Finally, it points the new properties to the values the old properties were pointing to:

let catCopy = {
  likes: cat.likes,
  favoriteHuman: cat.favoriteHuman,
};

If you evaluate the cat.likes, you get the string value "cuddles". This is a string primitive value that you can’t mutate. If you evaluate the cat.favoriteHuman, you get the object value { name: "Mary" }. That object is not a new object but the same object for both the cat and the catCopy. So, this is the problem. Object values are mutable, so if you mutate the catCopy.favoriteHuman, the original object will also see the change in cat.favoriteHuman.

You can verify that both properties point to the same object with a strict equality check (===):

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = Object.assign({}, cat);

console.log(cat.favoriteHuman === catCopy.favoriteHuman);
// Prints: true

What you would probably want for the favoriteHuman is to point to a new object value. Something like this:

let catCopy = {
  likes: cat.likes,
  favoriteHuman: cat.favoriteHuman,
};

let catCopy = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};

Now, you can mutate the catCopy object without worrying about the original cat object. This is what deep copy does.

Deep copy

Now let’s see what happens when you make a deep copy. Let’s use lodash.clonedeep for that:

import cloneDeep from "lodash.clonedeep";

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = cloneDeep(cat);

You can now mutate the properties of the new object:

import cloneDeep from "lodash.clonedeep";

let cat = {
  likes: "cuddles",
  favoriteHuman: { name: "Mary" },
};
let catCopy = cloneDeep(cat);
catCopy.likes = "food";
catCopy.favoriteHuman.name = "John";
console.log(cat);
// Prints:
// {
//   likes: 'cuddles',
//   favoriteHuman: { name: 'Mary' }
// }

As you can see, with a deep copy you can mutate the new object value without worrying about mutating the original object. The drawback is that a deep copy is more expensive in CPU cycles and memory.

Conclusion

If you don’t want to accidentally mutate your original objects:

  • Avoid passing around object values with the assignment operator, instead, use shallow copy.
  • If your objects are simple and have only primitive properties, you can shallow copy them and avoid performance overhead from deep copying.
  • If your objects are complex and have properties that point to other objects, you’ll have to deep copy them with a library like lodash.clonedeep.

Extra: higher-order functions

Somewhat related to the previous is the Array.prototype.map. With map, you take an array value and transform it into a new array value. You then assign that value to a new variable. You may think that because map creates a new array value, it’s safe to modify it. As it turns out, it depends on what type of value (primitive/object) you return from your transformer function.

In the following example, we have an array of cats (ok, 1 cat for brevity). We transform it into an array of people that are popular with cats.

let cats = [
  {
    likes: "cuddles",
    favoritePerson: { name: "Mary" },
  },
];
let peopleWithCats = cats.map((cat) => cat.favoritePerson);

// Mutate a person from the new array.
peopleWithCats[0].name = "John";

// Log in the console the original cat array.
console.log(cats);
// Result
// [
//  {
//    likes: "cuddles"
//    favoritePerson: { name: "John" },
//  }
// ]

The result shouldn’t be surprising because you return an existing object value from map. As a result, if you mutate the person object in the new array, you also mutate the person object inside the cat from the original cat array.

The same is true if you use the spread operator on the cat objects which performs a shallow copy. Notice that, in the following example, we create a copy of the cats array, not an array with people. We do that to illustrate the use of the spread operator:

let cats = [
  {
    likes: "cuddles",
    favoritePerson: { name: "Mary" },
  },
];
let catsCopy = cats.map((cat) => ({ ...cat }));

// Mutate a cat's favorite person from the new array.
catsCopy[0].favoritePerson.name = "John";

// Log in the console the original cat array.
console.log(cats);
// Result
// [
//  {
//    likes: "cuddles"
//    favoritePerson: { name: "John" },
//  }
// ]

A better way is to return a new object value inside map. In the next example, we create an array with people again:

let cats = [
  {
    likes: "cuddles",
    favoritePerson: { name: "Mary" },
  },
];
let peopleWithCats = cats.map(({ favoritePerson: { name } }) => ({
  // Name is a primitive value, so it's safe to return.
  name,
}));
// Or, because the people objects have only primitive properties,
// you can use the spread operator e.g. { ...favoritePerson }

// Mutate a person from the new array.
peopleWithCats[0].name = "John";

// Log in the console the original cat array.
console.log(cats);
// Result
// [
//  {
//    likes: "cuddles"
//    favoritePerson: { name: "Mary" },
//  }
// ]

As a result, you’ll have to know what value types you return from higher-order functions because it’s possible to mutate the original arrays.

Other things to read

Popular

Previous/Next