DEV Community

Cover image for Forever Functional:  Immutable objects for safer state
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Originally published at blog.openreplay.com

Forever Functional: Immutable objects for safer state

by author Federico Kereki

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!
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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};
Enter fullscreen mode Exit fullscreen mode

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

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app 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
Enter fullscreen mode Exit fullscreen mode

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

const newSelf = { ...myself };
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)