Arrays, objects and mutations
Published on March 06, 2017 under the category JavaScriptHere are some thoughts on how to avoid mutations when working with arrays and objects in JavaScript by treating them as if they were immutable data structures.
What's a mutation?
An immutable value is one that, once created, can never be changed. In JavaScript, primitive values such as numbers, strings and booleans are always immutable. However, data structures like objects and arrays are not.
By mutation I mean changing or affecting a source element. The goal is to keep the original element unchanged at all times.
A mutation is a side effect: the fewer things that change in a program, the less there is to keep track of, which results in a simpler program.
I've marked techniques which involve a mutation to the source element with a ❌ whereas immutable methods are marked with a ✅.
Adding new elements to an array
Array.prototype.push
❌Array.prototype.unshift
❌Array.prototype.concat
✅- Spread Operator (ES6) ✅
Array.prototype.push
allows us to push
elements to the end of an array. This method does not return a new copy, rather mutates the original array by adding a new element and
returns the new length
property of the object upon which the method was called.
const numbers = [1, 2];
numbers.push(3); // results in numbers being [1, 2, 3]
There's also Array.prototype.unshift
which we can use to add elements to the very beginning of an array. Just as push
, unshift
does not return a new copy of the modified
array, rather the new length
of the array.
const numbers = [2, 3];
numbers.unshift(1); // results in numbers being [1, 2, 3]
In these two examples we are mutating or changing the original array. Remember, the goal is to keep the source array untouched. Let's go through some alternatives to adding new elements to an array without altering the source.
Perhaps the easiest way out would be to create a copy of the source array and push directly to it:
const numbers = [1, 2];
const moreNumbers = numbers.slice();
moreNumbers.push(3);
We could call this a "controlled mutation" as we are indeed mutating an array, although not the original one, but a copy we created for this
purpose. However, Array.prototype.slice
can
be tricky as it does not create a deep clone, rather a shallow copy of the source array.
Array.prototype.concat
does indeed return a
new array, which is what we are after. We can make use of this method to return a new array with an extra element (or elements) appended to
the original one:
const numbers = [1, 2];
const moreNumbers = numbers.concat([3]);
Just a little heads up: concat
also returns a shallow copy of the source array (just as slice
does). This means that it only copies
the references to both objects and other arrays within our original list. Shall you want to have a complete brand new copy of your
array, consider using Lodash's cloneDeep
which recursively clones the array or object passed in
as a param:
const people = [{ name: 'Bob' }, { name: 'Alice' }];
const morePeople = cloneDeep(people).concat([{ name: 'John' }]);
console.log(people[0] === morePeople[0]); // returns false
Shallow copies vs deep clones is a recurrent topic throughout this post. Remember to always use cloneDeep
if the elements of your
source array are other arrays or objects and you need to keep them untouched. However, bear in mind deep cloning is an expensive operation
in terms of performance. You might not always need a deep clone — in fact, and as a rule of thumb, you should probably avoid creating deep
clones as long as you can: just consider whether you really need them and, in case you do, be aware of the tradeoffs.
Going back to adding new elements to an array in an immutable way, we could also use the
spread operator, available since ES6, as a
replacement to concat
:
const numbers = [1, 2];
const moreNumbers = [...numbers, 3];
So far we have added elements either to the very beginning or very end of the array, but it's also possible to add elements to any position
by using Array.prototype.splice
. Bear in
mind splice
changes the source array.
const positions = ['First', 'Second', 'Fifth', 'Sixth', 'Seventh'];
positions.splice(2, 0, 'Third', 'Fourth');
In this example, the second argument (0
) means "do not remove anything".
Again, we can achieve the same thing without mutating the original array by using concat
:
const positions = ['First', 'Second', 'Fifth', 'Sixth', 'Seventh'];
const morePositions = positions.slice(0, 2).concat(['Third', 'Fourth']).concat(positions.slice(2));
And once again, the spread operator gets the job done as well:
const positions = ['First', 'Second', 'Fifth', 'Sixth', 'Seventh'];
const morePositions = [...positions.slice(0, 2), 'Third', 'Fourth', ...positions.slice(2)];
Adding new properties to an object
- Direct addition ❌
Object.assign
(ES6) ✅- Object spread operator (experimental, not yet standardised) ✅
We can easily add new properties to an object by setting them directly. Of course, this constitutes a mutation to the original object:
const person = { name: 'John Doe', email: '[email protected]' }; // Using dot notation
person.age = 27; // Using array notation
person['nationality'] = 'Australian';
Probably the most widespread solution to add further properties without changing the source object is to use
Object.assign
or Lodash's
assign
(they both have the same signature):
const person = { name: 'John Doe', email: '[email protected]' };
const samePerson = Object.assign({}, person, {
age: 27,
nationality: 'Australian',
});
A note on Object.assign
: see how the first argument is a new, empty object? Well, it's important to know Object.assign
mutates the first
parameter you pass on to it. That's why I'm passing {}
and not person
as the first argument, as I don't want person
to be modified by
this operator. The takeaway here is to always pass in an empty object as the first param if you want to keep the source objects
untouched.
Similarly to what happens with arrays, we can make use of the object spread operator which by the way is not yet standardised. The object spread operator is conceptually similar to the ES6 array spread operator.
const person = { name: 'John Doe', email: '[email protected]' };
const samePerson = { ...person, age: 27, nationality: 'Australian' };
Removing elements from an array
Array.prototype.splice
❌Array.prototype.pop
❌Array.prototype.shift
❌Array.prototype.slice
&Array.prototype.concat
✅Array.prototype.slice
& the ES6 Spread Operator ✅Array.prototype.filter
✅
Array.prototype.splice(index, number)
removes number
elements starting from index
, and returns an array of the removed elements.
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
cities.splice(2, 1); // removes 1 element from the 2nd index (Cork)
Array.prototype.pop
can be used to remove
elements from the end of the array. It also returns the removed element. This is kind of the inverse function of Array.prototype.push
.
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const bern = cities.pop(); // removes Bern from the list of cities
Then we've got Array.prototype.shift
which
removes the first element of an array and returns it as well. This would be the inverse function of Array.prototype.unshift
.
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const oslo = cities.shift(); // removes Oslo from the list of cities
splice
, pop
and shift
all mutate the source array. Let's now go through some techniques to keep the original array untouched.
Let's say we want to derive a capitals
array from the list of cities in the previous examples, meaning we need to remove Cork and keep
all of the others. To achieve this we can use a combination of slice
and concat
to create a copy of a slice of the source array (from
the beginning up to the second element, which is not included) and then concatenating it with another slice of the original array, this time
from the third position onwards. This way we got rid of the element at the second index (in this case, Cork).
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const capitals = cities.slice(0, 2).concat(cities.slice(3));
We can achieve the same thing using the spread operator:
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const capitals = [...cities.slice(0, 2), ...cities.slice(3)];
We could also use Array.prototype.filter
to
filter out the element (or elements) we want to get removed. This method returns a new array.
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const capitals = cities.filter((city) => city !== 'Cork');
We can also filter by index:
const cities = ['Oslo', 'Rome', 'Cork', 'Paris', 'London', 'Bern'];
const capitals = cities.filter((city, index) => index !== 2);
Removing properties from an object
delete
operator ❌- Object destructuring ✅
- Lodash's
pick
andomit
✅
We can remove properties from an object by using the
delete
operator. This, of course, changes or mutates
the source object.
const person = { name: 'John Doe', email: '[email protected]', age: 27 }; // dot notation
delete person.age; // array notation
delete person['age'];
In order to avoid mutations we can easily use the ES6 spread operator (object destructuring):
const person = {
name: 'John Doe',
email: '[email protected]',
age: 27,
country: 'Australia',
language: 'English',
profession: 'Front End Developer',
};
const { profession, country, ...newPerson } = person;
console.log(newPerson);
where profession
and country
are the properties we want removed from the new object.
We can also resort to Lodash's pick
and omit
. They both have the same
signature: first argument is the object you are gonna work on, whereas the second one can be either a string or an array of strings of the
property or properties we want preserved or removed. They both return a new object.
const person = { name: 'John Doe', email: '[email protected]', age: 27 };
const fewerDetails = _.omit(person, 'age'); // or we could use pick which is the inverse of omit
const fewerDetails = _.pick(person, ['name', 'email']);
The takeaway
- There are contexts in which mutation is not allowed (e.g. Redux reducers), but it's fine in many other cases. This is why it's essential to differentiate between observable and unobservable mutations. If you create an object in a function and then just need to populate it, it's not very clever to try to avoid a mutation, let alone very inefficient. This thread is a good read on the topic.
- Remember that the spread operator,
Array.prototype.concat
,Array.prototype.slice
, etc. they only return a shallow copy of the array. If this is not good enough for your use case, use Lodash'scloneDeep
. - Also remember that returning a new array/object instead of mutating the original one, and particularly deep clones, are expensive in terms of performance. Just be aware that keeping data structures immutable comes with a price.
Credits
This post is heavily inspired on the need to keep state immutable on Redux reducers and touches on concepts taught by Dan Abramov on his Redux course.