In this article you will learn about all the differences between deep and shallow copies in JavaScript: why we should use them, how to create them, and when to use one and when the other to avoid any potential issues.

Would you rather watch the video?

The first thing worth explaining, even before moving on to copies, are data values in JavaScript. There are 8 data types which belong to two different value types: primitives and objects. Let’s see how the two differ.

Primitives are all the data types except objects. That means:

  • Boolean
  • Null
  • Undefined
  • String
  • Number
  • Bigint
  • Symbol

Primitives are immutable (they do not have methods or properties that can alter them).

Objects are all other JavaScript elements, such as object literals, arrays, dates, etc., and they are mutable. That means that certain methods can alter them.

All primitives in any programming language are passed by value and all non-primitives are passed by reference (more about that later).

Let’s see some examples.

An example that proves primitives are immutable:

const language = "JavaScript";
language[0] = "L";

console.log(language);
// "JavaScript"

When attempting to change the first letter of a string “J” to “L,” we receive the language variable not altered.

Note: The above would only happen in a “non-strict mode.” In a “strict mode” we will receive an error:

'use strict' 
const language = "JavaScript";
language[0] = "L";

console.log(language);
// Uncaught TypeError: Cannot assign to read only property '0' of string 'JavaScript'

And an example that proves objects are mutable:

const language = ["J", "a", "v", "a"];
language[0] = "L";

console.log(language); 
// ["L", "a", "v", "a"]

Here we can see that changing the first element of the array resulted in mutating the language variable.

To make a copy of a primitive value, we don’t need to do much. Let’s see how we can achieve it easily.

const language = "JavaScript";
_language = language;

console.log(language);
console.log(_language);

// "JavaScript"
// "JavaScript"

Assigning a source variable to a new one is sufficient to make a copy of it and will not cause any issues since primitives are immutable. However, the method should be avoided for copying objects. Let’s find out why.

const languageObj = {name: "JavaScript", age:26}; 
_languageObj = languageObj;

console.log(languageObj); 
console.log(_languageObj);

// {name: "JavaScript", age:26} 
// {name: "JavaScript", age:26}

From the example above we can see that assigning an object variable to another gives us a new variable with the same value. It seems that we received what we wanted and it is all good. But in reality it’s not. And this is why:

const languageObj = {name: "JavaScript", age:26}; 
_languageObj = languageObj;

_languageObj.age = "I don't know";

console.log(languageObj); 
console.log(_languageObj);

// {name: "JavaScript", age: "I don't know"} 
// {name: "JavaScript", age: "I don't know"}

So… what has just happened? Applying changes to the newly created variable mutated them both, but how? To understand that, first we need to go back in time a bit to brush up on the fundamentals of computer science.

In programming languages there are two spaces to store data in computer memory: the stack and the heap.

The stack is a temporary storage memory to store local primitive variables and references to objects.

The heap stores global variables. Object values are stored on the heap and the stack contains just references to them (pointers).

Let’s take a look at the image below.

As it was mentioned at the beginning, primitives are “passed by value.” And from the illustration we can see that, simply speaking, after creating two strings — language and _language — from which the second one is made by assigning the language value to it, we receive two separate values and there is no connection between them. They are stored on the stack.

And creating objects the same way resulted in creating two pointers (references) in the stack and only one value in the heap. Bearing that in mind, we can come to the conclusion of what “passed by reference” really means. Once we make an object variable, the memory saves the “address” to the actual location of the value and not the value itself. In the example, we receive two references that point to the same value and this is why mutating one of the objects will always alter both of them. And this is also the reason why we need copies.

We have two kinds of object copies in JavaScript: shallow and deep. In a nutshell, shallow copies are used for “flat” objects and deep copies are used for “nested” objects.

By “flat” objects we mean objects that contain only primitive values.

For instance: [1, 2, 3, 4, 5]

Nested objects mean objects that contain non-primitive values.

For instance: [“laptop”, {value: 5000}]

To create a shallow copy, we can use the following methods:

  • Spread syntax […] {…}
  • Object.assign()
  • Array.from()
  • Object.create()
  • Array.prototype.concat()

And to create a deep copy, we can use:

  • JSON.parse(JSON.stringify())
  • structuredClone()
  • Third party libraries like Lodash

Time for some examples. Let’s create a shallow copy first.

const numbers = [1, 2, 3, 4, 5];
const _numbers = [...numbers];

_numbers[0] = 10;

console.log(numbers); 
console.log(_numbers); 
// [1, 2, 3, 4, 5]
// [10, 2, 3, 4, 5]

For objects such as those in the example above, a shallow copy is sufficient. Since the source object consists only of primitive values, it will not share any references with the created copy and any changes will apply only to the directly mutated object. Hence, we obtain an unaltered numbers object and a mutated _numbers copy.

Spread syntax

The example with variable numbers uses spread syntax. That method can be used with iterables (values that can be looped-over, e.g. arrays and strings) and object literals, to spread them in places where we expect zero and more elements.

Note: Do not mistake spread syntax with rest syntax, which looks exactly the same but does the opposite thing — instead of spreading, it is collecting elements and concentrating them into one (array of arguments). See the example:

const tryRest = (...args) => {
  console.log(args); //[1, 2, 3, 4, 5]
}

tryRest(1, 2, 3, 4, 5);

There is also the concat method, very similar to spread syntax.

The concat method merges two or more arrays into one and returns a new array. See the example below:

const numbers1 = [1, 2, 3, 4];
const numbers2 = [5, 6, 7, 8];

const numbersAll = numbers1.concat(numbers2);

console.log(numbers1); // [1, 2, 3, 4];
console.log(numbersAll); // [1, 2, 3, 4, 5, 6, 7, 8];

In comparison to the spread syntax, concat is a better choice for large array sizes because it is faster. It can be used to avoid “Maximum call stack size excited” error, which may occur with spread syntax. On the other hand, spread is more efficient for smaller arrays.

Spread syntax can also be used as a shorter alternative for Object.assign() with object literals. Although the two methods are very similar, they still have some differences. Let’s see what they are.

Object.assign() is a special method dedicated to object literals and is used to copy enumerable properties from a source object to a target object.

const targetObj = {a: 1, b: 2};
const sourceObj = {b: 3, c: 4};

const result = Object.assign(targetObj, sourceObj);

console.log(targetObj); // {a: 1, b: 3, c: 4}
console.log(result); // {a: 1, b: 3, c: 4}
console.log(source0bj); // {b: 3, c: 4}

And before we explain the example above, take a look how the assignment would look like if we used spread syntax.

const targetObj = {a: 1, b: 2};
const sourceObj = {b: 3, c: 4};

const result = {...targetObj, ...source0bj};

console.log(targetObj); // {a: 1, b: 2} 
console.log(result); // {a: 1, b: 3, c: 4}
console.log(source0bj); // {b: 3, c: 4}

The main difference is that Object.assign() mutates the target object while spread syntax does not.

If we want to use Object.assign() method but we don’t want to alter any object, we can use an empty object as the first argument, like:

const result = Object.assign({}, obj1, obj2);

Another difference is that Object.assign() triggers setters on the target object while spread syntax doesn’t.

Additionally, when we use Object.assign(), new elements are pushed at the end of the target object, whereas with spread syntax we can choose where we want to place them.

Now, let’s move on to another method used to clone a little bit different type of objects, such as Set and Map, which is Array.from().

Array.from() is a static method that creates a new array from an iterable or array-like (indexed elements) object.

See an example of creating a shallow copy of Set object.

const set = new Set(['a', 'b', 'c', 'a']); 
const array = Array.from(set);

console.log(set); // {'a', 'b', 'c'} 
console.log(array); // ['a', 'b', 'c']

Note: creating a Set removes all repetitions of the array elements.

The last example for shallow copies is the Object.create() method.

Object.create() is a method that creates a new object from an existing one. It takes two arguments. The first one is the object which we want to copy (it becomes a prototype for our clone). The other one is optional and allows us to set properties of the newly created object.

See an example of a shallow copy created using Object.create() method:

const language = {name: "JavaScript", age: 26};
const _language = Object.create(language);
_language.age = 200;

console.log(language);
//{name: "JavaScript", age: 26};

console.log(_language);
//{name: "JavaScript", age: 200};

OK, we got off topic a bit, so let’s move back to our copies…

The problem with shallow copies arises for nested objects.

const language = ["JavaScript", {age: 26, creator: "Brendan Eich"}]; 
const language = [...language];

_language[1].age = 126;

console.log(language);
console.log(_language);

// ["JavaScript", {age: 126, creator: "Brendan Eich"}] 
// ["JavaScript", {age: 126, creator: "Brendan Eich"}]

In this case, changing the copy also altered the source value and the reason for that is their shared references (they both point to the same value). Luckily, there is a deep copy too, a great solution for that issue.

Let’s create a deep copy of the same object.

const language = ["JavaScript", {age: 26, creator: "Brendan Eich"}];
const _language = JSON.parse(JSON.stringify(language));
const __language = structuredClone(language);

_language[1].age = 126;
__language[1].age = 1;

console.log(language); 
console.log(_language);
console.log(__language);

// ["JavaScript", {age: 26, creator: "Brendan Eich"}] 
// ["JavaScript", {age: 126, creator: "Brendan Eich"}] 
// ["JavaScript", {age: 1, creator: "Brendan Eich"}]

As we can see, creating a copy that way solved the problem and any mutations apply only to the directly altered objects. This is because the copies do not share any references with the source object anymore. They are all separate values.

Let’s also take a closer look at the methods that we used to make those deep copies.

The first way to make a deep copy of an object in JavaScript is converting it to a JSON string by using JSON.stringify() method, and then reverting it back to object with JSON.parse(). This way we receive a completely new object with no shared references with the source object. But the method can only be used with serializable objects and will not work with, for instance,  functions, Symbols, and recursive data.

The other method — structuredClone() — is a great alternative to clone serializable objects. Its second optional argument allows us to transfer the original value to the new object. That means that transferred data is detached from the source object and is no longer accessible there.

Note: structuredClone() is a feature of browsers, not JavaScript itself.

To sum up, shallow copies share references with source objects, they are more appropriate for not nested objects and are commonly used in programming with JavaScript. But if we have to deal with nested objects or we don’t know what values we receive (from the API, for instance), we should use deep copies instead, as they do not share any references with original objects.

And if you need a development team passionate about their craft…

Let’s talk!

Aneta Narwojsz is a dedicated Frontend Developer at Makimo, harnessing her profound expertise in JavaScript and other frontend technologies to deliver remarkable web solutions. Known for her knack for translating complex JavaScript fundamentals into easily digestible content, she regularly publishes enlightening articles and engaging video materials. When she's not immersed in code, Aneta unwinds with her favorite pastimes: visiting the cinema, immersing herself in books, experimenting in the kitchen, and exploring fashion trends.