Back

Immutable objects for safer state

 Immutable objects for safer state

In our previous article, Forever Functional: Injecting for Purity we mentioned that pure functions do not produce any observable side effects when they do their work, and this includes not modifying anything “in the outside”. However, we all know that objects, even if declared as const, may be modified. Global state is thus a probable target for “side effects”: any function may change it, and lead to hard-to-understand situations. In this article, we’ll discuss how to work with immutable objects for safer state handling, so accidentally changing them will become harder or directly impossible.

It’s impossible to enforce a rule that will make developers work in safe, guarded ways. However, if we can manage to make our data structures immutable (and we’ll keep state in such an object) we’ll control how the state is modified. We’ll have to work with some interface that won’t let us directly modify the original data, but rather produce a new object instead. (Observant readers may notice that this is exactly the idea behind Redux: you don’t directly modify state: you have to dispatch an action, which a reducer will process to produce a new state.)

Here we’ll study the varied ways that JavaScript provides, such as object freezing and cloning, so we can get a basic understanding of what a full immutability solution needs. We won’t try, however, to achieve 100% production-ready code, possibly dealing with getters, setters, private attributes, and more; for this I would rather suggest looking at available libraries such as Immutable, Immer, Seamless-immutable, and others.

We will consider the following options:

  • Using const declarations to prevent (some) variables from being changed
  • Freezing objects to avoid all modifications
  • Creating a changed clone to avoid modifying the original object
  • Avoiding mutator functions that modify the object to which they are applied
  • Other more obscure ways

Let’s get started!

Using const declarations

The first solution we could think of is using const declarations to make variables immutable. However, this won’t work with objects or arrays, because in JavaScript a const definition applies to the reference to the original object or array, and not to their contents. While we cannot assign a new object or array to a const variable, we can certainly modify its contents.

const myself = { name: "Federico Kereki" };
myself = { name: "Somebody else" }; // this throws an error, but...
myself.name = "Somebody else";      // no problem here!

We cannot replace a const variable, but we can modify it. Using constants works for other types, such as booleans, numbers, or strings. However, when working with objects and arrays we’ll have to resort to other methods, such as freezing or cloning, which we’ll consider below.

Avoiding mutator functions

A source of problems is that several JavaScript methods work by mutating the object to which they are applied. If we just use any of these methods, we will be (knowingly or unknowingly) causing a side effect. Most of the problems are related to arrays and methods such as:

  • fill() to fill an array with a value
  • sort() to sort an array in place
  • reverse() to reverse the elements of an array, in place
  • push(), pop(), shift(), unshift(), and splice() to add or remove elements from an array

This behavior is different from other methods, like concat(), map(), filter(), flat(), that do not modify the original array or arrays. There’s an easy way out for this, fortunately. Whenever you want to use some mutator method, generate a copy of the original array, and apply the method to it. Let me give you an example out of my Mastering JavaScript Functional Programming book. Suppose we want to get the maximum element of an array. A possible way we could think of is first sorting the array and then popping its last element.

const maxStrings = a => a.sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings(countries)); // "Uruguay"
console.log(countries); // ["Argentina", "Brasil", "Paraguay"]

Oops! We got the maximum, but the original array got trashed. We can rewrite our function to work with copies.

const maxStrings2 = a => [...a].sort().pop();
const maxStrings3 = a => a.slice().sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings2(countries)); // "Uruguay"
console.log(maxStrings3(countries)); // "Uruguay"
console.log(countries); // ["Argentina", "Uruguay", "Brasil", "Paraguay"] - unchanged

Our new versions are functional, without any side effects, because now we are applying mutator methods to a copy of the original array. This is a solution, but it depends on developers being careful; let’s see if we can do better.

Freezing objects

JavaScript provides a way to avoid accidental (or intentional!) modifications to arrays and objects: freezing. A frozen array or object cannot be modified, and any attempt at doing this will silently fail without even an exception. Let’s review the example from the previous section.

const myself = { name: "Federico Kereki" };
Object.freeze(myself);
myself.name = "Somebody else";      // won't have effect
console.log(myself.name);           // Federico Kereki

This also works with arrays.

const someData = [ 22, 9, 60 ];
Object.freeze(someData);
someData[1] = 80;                   // no effect
console.log(someData);              // still [ 22, 9, 60 ]

Freezing works well, but it has a problem. If a frozen object contains unfrozen objects, those will be modifiable.

const myself = { name: "Federico Kereki", someData: [ 22, 9, 60 ] };
Object.freeze(myself);
myself.name = "Somebody else";      // won't have effect
myself.someData[1] = 80;            // but this will!
console.log(someData);              // [ 22, 80, 60 ]

To really achieve immutable objects through and through, we have to write code that will recursively freeze the objects and all their attributes, and their attributes’ attributes, etc. We can make do with a deepFreeze() function as the following.

const deepFreeze = obj => {
  if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
    Object.freeze(obj);
    Object.getOwnPropertyNames(obj).forEach(prop => deepFreeze(obj[prop]));
  }
return};

Our deepFreeze() function freezes the object in place, the same way Object.freeze() does, keeping the semantics of the original method. When coding this one must be careful with possibly circular references. To avoid problems, we first freeze an object, and only then do we deep freeze its properties. If we apply the recursive method to an already frozen object, we just don’t do anything.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.

Creating (changed) clones

Freezing is a possibility, but sometimes one needs to produce a new (mutated) object or array. As we mentioned, Redux works this way: reducers are functions that get the current state and an action (some new data) and apply the new data to the state to produce a new, updated state. Redux prohibits modifying the current state: a new object must be produced instead.

How can we clone an object? Just copying won’t do: something like the following just creates a new reference to the same object.

const myself = { name: "Federico Kereki", otherData: { day:22, month:9 } };
const newSelf = myself;    // not a clone; just a reference to myself

For simple objects, spreading is a (partial!) solution.

const newSelf = { ...myself };

However, newSelf.otherData still points to the original object in the myself object.

newSelf.otherData.day = 80;
console.log(myself.otherData.day);   // 80, not 22!

We can write a deepCopy() function that, whenever copying an object, builds a new one by invoking the appropriate constructor. The following code is also taken from my book, referred above.

const deepCopy = obj => {
  let aux = obj;
  if (obj && typeof obj === "object") {
    aux = new obj.constructor();
    Object.getOwnPropertyNames(obj).forEach(
      prop => (aux[prop] = deepCopy(obj[prop]))
    );
  }
  return aux;
};

So, we have two ways to avoid problems with objects: freezing (to avoid modifications) and cloning (to be able to produce a totally separate copy). We can achieve safety, now!

Other (more obscure) ways

These are not the only possible solutions, but they are enough to get you started. We could also consider some other methods, such as:

  • adding setters to all attributes, so modifications are forbidden

  • writing some generic setAttributeByPath() function that will take an object, a path (such as "otherData.day") and a new value, and produce an updated object by making a clone of the original one

  • or getting into even more advanced Functional Programming ideas such as optics. This includes lenses (to get or set values from objects) and prisms (like lenses, but allowing for missing attributes)… But that’s better left to another article!

Finally, don’t ignore the possibility of using some available libraries, such as immutable.js and many others.

Summary

In this article, we’ve gone over the possible problems caused by unwanted side effects when dealing with objects and arrays. We’ve considered some ways to solve them, including freezing, cloning, and the avoidance of mutators. All these tools are very convenient, and many Web UI frameworks assume or require immutability, so now you’re ahead of those requirements!