Before we dive into the actual topic, let us look at the theory behind it. This is mainly about the type system of JavaScript and how the engine handles the different types. And yes, JavaScript does have a type system, albeit a weak one. There are two different types in JavaScript: primitive and composite. Primitive types are, for example, strings, numbers, or booleans. Composite types are arrays and objects. The main difference between the two types is that you assign primitive types by value and composite types by reference. The best way to see the effect that the assignments have is to look at a concrete example in Listing 1.
Listing 1: Changes in Data Structures
let str = 'Hello World!'; let obj = { name: 'Klaus', }; function mutate(s, o) { s = 'Good morning'; obj.name = 'Peter'; console.log(s); console.log(obj); } mutate(str, obj); console.log(str); console.log(obj);
The code first defines two variables: a string and an object. The mutate function has two parameters: a string and an object. The function modifies both arguments. The string, which is passed by value, is replaced with the value Good morning, and the object’s name property is changed to the value Peter. To check this, we print both values to the console. Finally, in the example, we call the function and write both values to the console. You can see the output of the script in Listing 2, regardless of whether you run it in the browser or via Node.js.
Listing 2: Output of Our Example
Good morning { name: 'Peter' } Hello World! { name: 'Peter' }
The first output is the string Good morning, so the first argument has a new value, just like the name property of the passed object. The output outside the function looks a little different: The primitive variable remains unchanged at the original value and the object has inherited the modification from the function. The reason for this is that when you call the function, for the primitive value you pass a copy of the value to the function, and for the composite type you simply pass a reference to the object’s memory area. This means that a change in the function modifies the original object. You could theoretically take advantage of this quirk of JavaScript and do away with return values from functions. The problem at this point is that a function modifies its environment in this way without it being directly apparent from its signature. The manipulation is thus a side effect of the function. In individual cases, such side effects can still be okay, but if an application grows or the functionality is outsourced to a separate library, side effects are no longer appropriate, as they bring with them a large potential for errors.
At this point, of course, we can ask the question: Can’t something be done about the mutability of objects?
Protect Objects Against Changes by Freezing Them
The short and simple answer is: yes, it is possible to protect an object from modification. The JavaScript standard provides the Object.freeze method for such cases. You pass the object you want to protect to it and afterwards no more changes are possible. For our example, this means that you need to insert the line Object.freeze(obj), as in Listing 3.
Listing 3: Protect Objects Against Changes
let obj = { name: 'Klaus' }; Object.freeze(obj); function mutate(o) { o.name = 'Peter'; console.log(o); } mutate(obj); console.log(obj);
The result of this change takes some getting used to. The attempt to manipulate the object is simply ignored; the JavaScript engine does not give any feedback in the form of an exception. Now you could take this a step further and check if the object is read-only. For this, you can use the Object.isFrozen() method. However, this would require you to assume that the object is potentially read-only for all changes. So that is not an option either. The real alternative lies in another form of immutability.
Immutable Data Structures
The idea of immutable data structures is that you don’t work on the original data structure, as in the example, but create a copy and change it. Now you are probably wondering if it is really such a good idea to copy objects over and over again instead of simply changing a value. On closer inspection, this approach is somewhat memory intensive. However, there is a very good reason in favor of copying object structures, anyway. Imagine an object structure with several levels of depth and that you make a change in one of these deep levels. Now let’s assume an algorithm that updates the graphical interface every time you change the object structure. For this to work correctly, it would have to check all branches of the object tree for changes. However, if you copy the entire object structure, the algorithm knows that a change has occurred. And this is exactly the principle, albeit much more elegant, on which React, and its state management, works.
But back to our example: When you create a copy within the function, you also need to make sure that the copy is returned to the calling code so that it can work with it (Listing 4).
Listing 4: Immutable Data Structures
let obj = { name: 'Klaus' }; function mutate(o) { let copy = { ...o }; copy.name = 'Peter'; console.log(copy); return copy; } mutate(obj); console.log(obj);
In the example you can see how to create a copy of an object using the spread operator, i.e. the three dots. However, extreme caution is required here, as this operation only creates a flat copy of the object. Thus, if an object or array has substructures itself, they are merely copied by reference and the problem is only superficially solved.
Another way to copy an object in JavaScript is to misuse the JSON.stringify and JSON.parse methods to do something for which they were not really intended. So, you can first convert your object into a JSON string using JSON.stringify and then back into an object using JSON.parse, and you get a deep copy with the small disadvantage that all the object’s methods are gone. So that is not a reasonable solution to our problem either.
The clean solution is to write a function that creates a deep copy of an object or array structure. Fortunately, this isn’t exactly an exotic problem, so a few people have already tried their hand at a solution. Now the JavaScript world wouldn’t be what it is if there weren’t dozens of competing solutions by now, so I’ll now show you three representatives that approach the problem in different ways: Immutable, Immer, and immutability-helper.
Immutable – the Big, All-Encompassing Solution
The problem with immutability and JavaScript is that the language does not really support this construct. The library Immutable solves the problem by defining its own data types, such as Set, Map, and Collection. These data types provide methods that ensure operations on the data types are immutable. Our example can be implemented with the Map data type and then looks like Listing 5.
Listing 5: Immutable in Use
const { Map } = require('immutable'); let obj = Map({ name: 'Klaus' }); function mutate(o) { let copy = o.set('name', 'Peter'); console.log(copy.toJSON()); return copy; } mutate(obj); console.log(obj.toJSON());
For the example to work, you need to install Immutable using the npm i immutable command and run the source code of the example through Node.js. You can pass an initial structure to the Map type and then set values using methods such as set. As a return, you get a new, modified object of the Map type.
The disadvantage of Immutable is that you can no longer use the JavaScript types, but have to become familiar with Immutable. Furthermore, this also means that migrating away from Immutable usually means a lot of work.
Immer – the Lightweight Alternative
Immer takes a different approach to Immutable by relying on the native JavaScript data types and instead providing only one function with produce. Immer is installed using the command npm install immer. Listing 6 contains the source code of our example, which has been adapted to Immer.
Listing 6: Immer for Immutable Data Structures
const produce = require('immer').produce; let obj = { name: 'Klaus' }; function mutate(o) { let copy = produce(o, (draft) => { draft.name = 'Peter'; }); console.log(copy); return copy; } mutate(obj); console.log(obj);
You pass the original object as the first argument to the produce function and a function containing the operations you want to perform on the object as the second. Immer then ensures that these operations are not performed on the actual data structure, but on a copy that you receive as the return value of the produce function.
immutability-helper – Immutability with Commands
In some respects, the immutability-helper package is in a similar league to Immer. The difference between the two is that with immutability-helper you have access to a fairly limited set of commands for manipulating structures. After installing the package with the npm install immutability-helper command, you can customize the code of our example as shown in Listing 7.
Listing 7: Making Changes to Objects with immutability-helper
const update = require('immutability-helper'); let obj = { name: 'Klaus' }; function mutate(o) { let copy = update(o, { name: { $set: 'Peter' } }); console.log(copy); return copy; } mutate(obj); console.log(obj);
With immutability-helper you specify with commands how you want to modify the source object. You do this based on the original structure. In our example, we are talking about a change to the name property. Instead of the value of the property, you specify an object with the $set key to set the value followed by the new value.
Conclusion
As you have seen, there is a good case for immutability in JavaScript, even if it is not natively supported by the language. However, there is no reason to write a library yourself that provides immutability for JavaScript. Quite a few developers have done this before you in a more or less successful way. From the abundance of potential solutions, I have picked out three different and very popular ones and presented them to you.
My conclusion: Immutable takes things a step too far for my liking by defining its own data structures that you must use throughout an application, which makes it almost impossible to replace the library at a later time.
Immer and immutability-helper, on the other hand, represent lightweight solutions and can also be exchanged in an emergency. The only question here is whether you prefer to go the Immer way and define the changes to the data structures in a function or whether you prefer to describe the modifications using the commands of immutability-helper.
I would advise to try out the different libraries, and preferably not necessarily with such a trivial example as I have shown you, and then decide on a solution.