In JavaScript, like any other program languages, stores things in variables, which can be changed on the fly. This may be a problem because we may change things accidentally that is shared. Having lots of code share the same mutable state is hard to trace. It makes debugging and read the code hard.
For example, if we change the same array in different function as follows:
let arr = [];
const foo = () => {
arr = [1, 2, 3];
}
const bar = () => {
arr = [4, 5, 6];
}
Then the value of arr
changes depending on whether foo
or bar
is called last. If foo
is called then arr
is [1, 2, 3]
. On the other hand, if bar
is called then arr
is [4, 5, 6]
.
This is a problem because as the code gets more complex, then the more function calls there are. If lots of functions are doing things like this, then tracing the value is hard and debugging is confusing.
Also, it’s hard to read how the logic flows as functions are called with these side effects.
In this article, we look at how to do a deep copy of data to prevent mutation of shared state in a program by making deep copies of data. Also, we look at ways to prevent the mutation of data exposed from class methods.
Deep Copy
Nested Spreading
We can use the spread operator in each level of an object to do deep copying manually.
For example, given that we have the following object:
const obj = {
foo: {
bar: 1,
baz: 2
},
a: 3
}
We can make a deep copy of it as follows:
const objCopy = {
foo: {
...obj.foo
},
a: obj.a
};
As we can see, this is going to be a problem when we have more levels and properties. However, we do get a deep copy of the original object.
Deep Copy Via JSON.stringify and JSON.parse
We can call JSON.stringify
to return a string of an object and then call JSON.parse
on the stringify to return it to the original form.
This works for all properties and values that are supported by JSON, which means that entities like Symbols and functions are excluded.
For example, we can write:
const obj = {
foo: {
bar: 1,
baz: 2
},
a: 3
}
const objCopy = JSON.parse(JSON.stringify(obj));
objCopy
will be a deep copy of obj
. This is because a string if immutable and JSON.parse
returns a new parsed copy of the stringified object.
Copying an Instance of a Class
We can copy an instance of a class easier than with objects. We can write a clone
method that returns the instance of the object to do this.
For example, we can write the following code to make one class that inherits from another class:
class Person {
constructor(name) {
this.name = name;
}
}
class Employee extends Person {
constructor(name, employeeCode) {
super(name);
this.employeeCode = employeeCode;
}
clone() {
return new Employee(this.name, this.employeeCode);
}
}
Then we call clone
to create a new duplicate object:
const employee = new Employee('Joe', 1);
const employeeClone = employee.clone();
console.log(employee.__proto__);
console.log(employeeClone.__proto__);
In the clone
method of the Employee
class, we return a new instance of an Employee
.
Then in the first 2 console.log
outputs, we see that both employee
and employeeClone
have the same prototype.
Why Does Copying Help Prevent Mutating Shared State?
Copying prevents the mutation of a shared state because we copied the shared data before we attempt to change it. This is handy because it stops us from accidentally making changes to shared data.
If we don’t make changes to shared data, then tracing it is easy. Then we won’t have to worry about accidentally having different parts of our code mutating the same state.
Copying Exposed Internal Class Data Before Making Changes
Before making changes to internal class data, we should make a copy of it and then return it in a method. This prevents us from changing the class data directly.
For example, if we have the following class:
class NumArray {
constructor() {
this._arr = [];
}
add(num) {
this._arr.push(num);
}
getParts() {
return this._arr;
}
toString() {
return this._arr.join('');
}
}
and we call the methods in various ways by writing the following:
const numArr = new NumArray();
numArr.add(1);
numArr.add(2);
console.log(numArr.toString());
numArr.getParts().length = 0;
console.log(numArr.toString());
We can see that the first console.log
has the value '1,2'
, and the second one logs an empty string.
This is because we changed the length
property of the array exposed by the getParts
methods. We set it to 0, so the array is emptied.
To prevent this, we can copy the array before returning it in getParts
. We write the following instead:
class NumArray {
constructor() {
this._arr = [];
}
add(num) {
this._arr.push(num);
}
getParts() {
return [...this._arr];
}
toString() {
return this._arr.join(',');
}
}
Then when we make the same calls to the NumArray
class’ methods as follows:
const numArr = new NumArray();
numArr.add(1);
numArr.add(2);
console.log(numArr.toString());
numArr.getParts().length = 0;
console.log(numArr.toString());
We get '1,2'
in both console.log
outputs.
We can do a deep copy of data in various ways. First we can manually copy data by repeatedly using the spread operator in every level.
Also, we can use JSON.stringify
and JSON.parse
to copy data that can be included in JSON.
For class instances, we can make a method that returns instances of classes by return a new instance of the data with the same data. This preserves the data and the inheritance structure.
Finally, we can prevent data exposed in class methods from being modified by making a copy of it before return it in the method.