Deep copy vs shallow copy vs assignment operator

In this post, we’ll use some code to understand the differences between deep object copy, shallow copy, and assignment operator in JavaScript. Due to a recent bug in an application that I was developing, I decided to spend some time to understand the differences between those copying techniques. If you are in a similar situation, you came to the right place. You can find the code I list here in this codesandbox.

Assignment operator

In JavaScript, we have two groups of variables. The primitive types and the reference types. The primitives, according to MDN, include the string, number, boolean, null, undefined, and symbol. The reference types include only the objects. But in objects, we can also find the arrays and the functions. If we use the assignment operator ”=” to copy a primitive type, say a string variable to another string variable, we copy the value:

// Primitive type (string)
let primitive = "hello";
let primitiveCopy = primitive;
primitiveCopy = "hello world";
console.log(primitive);
// Result: hello

Because we copy the value, when we change the second variable, the first remains unchanged. Now, if we take a reference type, say an array, and try to do the same thing, we’ll notice that the first value changes this time:

// Reference type (array)
const array = [1, 2, 3];
const arrayCopy = array;
arrayCopy[0] = 4;
console.log(array);
// Result: [4, 2, 3]

So the assignment operator for reference types copies the reference, not the value. In other words, array and arrayCopy point to the same exact object in memory. They don’t store a value inside them but an address to that object. Because we’re focusing on objects, let’s do the same thing but for an object this time. We’ll create an object that has two properties. An object property(reference) and a string property(primitive):

// 1. Assignment operator
const object1 = {
  a: { alpha: 1 },
  b: 2
};
const withOperator = object1;
withOperator.b = 3;
withOperator.a.alpha = 2;
console.log(object1);
// Result:
// {
//   a: { alpha: 2},
//   b: 3
// }

Nothing new here, the result is expected as we saw previously with the arrays. Both of those objects (object1 and withOperator) point to the same memory address because they’re reference types, and we used the assignment operator.

Shallow copy

Now, let’s see what happens if we try to make a ”shallow copy”. We’ll understand what shallow means after we review the code. There are many ways we can make a shallow copy of an object:

We’ll choose Object.assign here:

// 2. Shallow copy
const object2 = {
  a: { alpha: 1 },
  b: 2
};
// the right part reads: assign object2 to a new object {}
const withShallow = Object.assign({}, object2);
withShallow.b = 3;
withShallow.a.alpha = 2;
console.log(object2);
// Result:
// {
//   a: { alpha: 2},
//   b: 2
// }

We can see that something changed here. The primitive property b remains unaffected, but the reference property (a) changed the same way as with the assignment operator. So when we “shallow” copy an object, we create a new top-level object (withShallow) with new primitive properties b. The reference properties (a) are copied by reference again, not by value. In other words, we end up with two different objects: object2 and withShallow. They have new primitive properties but the same reference properties. To test this, open your developer tools, and type the following:

const object2 = {
  a: { alpha: 1 },
  b: 2
};
const withShallow = Object.assign({}, object2);
console.log(object2.a === withShallow.a);
// Result: true

This, by the way, was the bug that made investigate the differences between those techniques.

Deep copy

Now let’s see what happens when we make a deep copy. We’ll use lodash.clonedeep for that:

import cloneDeep from "lodash.clonedeep";

// 3. Deep copy
const object3 = {
  a: { alpha: 1 },
  b: 2
};
const withDeep = cloneDeep(object3);
withDeep.b = 3;
withDeep.a.alpha = 2;
console.log(object3);
// Result:
// {
//   a: { alpha: 1},
//   b: 2
// }

As we can see, the top-level objects (object3 and withDeep) are different as are the primitive properties b AND the reference properties a.

Conclusion

So if you don’t want to mutate accidentally stuff, and you want to keep your sanity, you have 2 options:

  • If your objects are simple and have only primitive properties, you can shallow copy them with any of the previous methods, and avoid performance overhead from deep copying.
  • If your objects are complex or even recursive, you’ll have to deep copy them with lodash.clonedeep.

Higher-order functions (extra)

Here I list just some code from experiments with higher-order functions. It’s something that I should totally put into a different post, but I can’t resist because I find it a bit relevant to the subject. 4.a, 4.c, and 4.e are interesting.

// 4.a Higher order functions + mutation + return the same object
const arrayHOF = [
  {
    a: { alpha: 1 },
    b: 2
  }
];
const arrayHOFCopy = arrayHOF.map(a => a);
arrayHOFCopy[0].b = 3;
arrayHOFCopy[0].a.alpha = 2;
console.log("arrayHOF: (mutation + return the same) ", arrayHOF[0]);
// Result
// {
//   a: { alpha: 2},
//   b: 3
// }
// Oopsie, don't do this.

// 4.b Higher order functions + mutation + return a new object (primitives)
const arrayHOFb = [
  {
    a: { alpha: 1 },
    b: 2
  }
];
const arrayHOFbCopy = arrayHOFb.map(item => ({
  a: { alpha: item.a.alpha + 1 },
  b: item.b + 1
}));
arrayHOFbCopy[0].b = 3;
arrayHOFbCopy[0].a.alpha = 2;
console.log("arrayHOFb (mutation + return new + primitives):", arrayHOFb[0]);
// Result
// {
//   a: { alpha: 1},
//   b: 2
// }

// 4.c Higher order functions + mutation + return a new object but spread (reference)
const arrayHOFc = [
  {
    a: { alpha: 1 },
    b: 2
  }
];
const arrayHOFcCopy = arrayHOFc.map(item => ({ ...item }));
arrayHOFcCopy[0].b = 3;
arrayHOFcCopy[0].a.alpha = 2;
console.log("arrayHOFc (mutation + return new + reference): ", arrayHOFc[0]);
// Result
// {
//   a: { alpha: 2},
//   b: 2
// }
// Oopsie, be careful

// 4.d Higher order functions, no mutation, return new
const arrayHOFd = [
  {
    a: { alpha: 1 },
    b: 2
  }
];
const arrayHOFdCopy = arrayHOFd.map(item => ({
  a: { alpha: item.a.alpha + 1 },
  b: item.b + 1
}));
console.log("arrayHOFd (captains obvious): ", arrayHOFd[0]);
// Result
// {
//   a: { alpha: 1},
//   b: 2
// }
// duh..

// 4.e Higher order functions, mutation, return map nested properties
const arrayHOFe = [
  {
    a: { alpha: 1 },
    b: 2
  }
];
const nestedProperties = arrayHOFe.map(item => item.a);
nestedProperties[0].alpha = 2;
console.log("arrayHOFe (mutation + return nested): ", arrayHOFe[0]);
// Result
// {
//   a: { alpha: 2},
//   b: 2
// }
// Oopsie

Other things to read

Popular notes

Other posts