The Complete Guide to Immutability in TypeScript

Greg Pabian
Level Up Coding
Published in
11 min readMar 8, 2021

--

The most conservative definition of immutability boils down to having objects which respective states cannot change after the initial assignment. Investing into applying this software pattern to all objects in a project can provide returns in better readability, improved understanding of code and advanced thread safety (in systems which support threads). The following article provides all information I find relevant for TypeScript developers, so they can reason about the need for ubiquitous immutability in their projects, based on the underlying functional-programming theory, the native support of the aforementioned concept in the language, and the best available practices.

Arguments for Immutability

In the traditional object-oriented approach towards software architecture, any class instance may contain a state, associated and intrinsic only to a particular instance. State initialisation happens in a class constructor and applying changes to that state takes place when invoking class methods. Even though the described rules of engagement sound reasonable, the mutable aspect of classes heavily influences the structure of code that relies on mutability.

For clarity, I have to introduce several concepts to describe the drawbacks of architectures which support mutability, starting first with defining two types of functions in regard to their execution:

  • synchronous (it executes and returns immediately in the current execution context),
  • asynchronous (it executes in a different execution context and getting the return value requires awaiting on it in the current context).

Depending on the environment, if a closure calls an asynchronous function without awaiting its completion, and the function does mutate some state, then nobody can reliably reason about visibility of the changes to that state in that closure after the call. No such side effects apply to synchronous calls — however, all previous calculations based on the state in question become invalidated after the synchronous function has mutated the state from within.

Secondly, I could distinguish between immutable and mutable functions. Calling an immutable function exhibits no side effects (no alteration of any state), having only one effect (the word comes from the world of functional programming) — the value returned from it. A mutable function has the potential to change a state, which, by definition, produces side effects.

Thirdly, in the object-oriented architecture, a developer can define getters and setters for any class. A getter behaves as an immutable function that returns a selected state property to its caller. A setter, on the other hand, mutates the state by setting a particular value on a selected state property.

Finally, combining all these concepts together, an avid reader should see the potential complexity arising from any state being mutated in different places in code and over various execution contexts (thread or callback alike). Debugging or simple reasoning about such code, without ensuring first that developers do follow explicit guidelines (which shield them from future intricacies), might prove head-aching even for people capable of working in complicated systems. This defines the fundamental reason for applying immutability — the desire to reduce the context of execution to its required minimum to support maintainability of the project in question.

Immutability in JavaScript

JavaScript, a multi-paradigm language, implements certain aspects of functional programming without forcing functional way of thinking upon its developers. The language itself does support immutability, one of the core concepts of the aforementioned paradigm, with some strings attached to it. Most burden comes from the fact that utilising immutability requires performing clearly expressed operations on the objects in question.

Primitive and Wrapper Types

There exist the following primitive types — boolean, number, bigint, string, symbol, null and undefined. Neither of them has associated methods; primitive values, which stem from these types, behave in an immutable way, which means passing them around in functions results in no side effects. From my experience, most JavaScript developers do not know of five wrapper (object) types, namely Boolean, Number, BigInt, String and Symbol, as the language allows for (implicit) interchangeable use of primitives and wrapper objects in most cases, hiding the true nature of wrappers.

Any object (any non-primitive, including functions), until proven otherwise, might expose mutating behaviour, as it contains some methods. An object, by definition, has an associated prototype (also an object), and altering the prototype might change the behaviour of the said object. I find it best to consider the whole context of an object, including all nested properties and their respective attributes, down to the last primitive, when dealing with mutability.

Variable Declaration

Developers should use either const or let to declare variables in newer JavaScript projects. I personally discourage using let as I expect developers to have trouble figuring out multiple reassignments to the same variables over dozens of lines of code. When in doubt, any experienced programmer could refactor the reassignments to a separate function that calculates the value of the variable in question and returns it.

Object Freezing

JavaScript contains functionality that makes objects shallowly immutable. I used shallowly to mark that, after conversion happens, no one can alter the object properties (including their attributes and values) except for nested structured within it. The Object.freeze function freezes an object, making it shallowly immutable, as shown in the snippet down below:

A demonstration of the freezing functionality in JavaScript

An avid reader might realise that Object.freeze works on the intrinsic state of the object in question, enforcing restrictions directly on the runtime level. Freezing an object removes its extensibility (ability to add new properties) and locks down the attributes of object properties, setting their writable and configurable keys to false. In order to achieve deep immutability, one should use a third-party library for such a task or write the implementation on their own (I would recommend the former as libraries usually cater for more edge cases that a single developer can envision).

Function Properties

Any function property, if understood as an object, exposes the potential for mutation. For simplicity, I would assume that passing objects into a function happens by (memory) reference and passing primitives happens by value. Interestingly, JavaScript allows for a reassignment of a function parameter using its name, which obviously has no effect outside the scope of the function, following the rules of naming within closures.

Immutability in TypeScript

TypeScript serves as a quantum leap in the JavaScript community, especially when taking immutability into regard. By leveraging the existence of the compile-time type system, a developer could forget about specifying and relying on restrictions on the runtime level, not to mention lowering the amount of code shipped to the end user. The language introduces the concept of typed readonly properties to achieve shallow immutability, as shown in the following paragraphs.

The readonly modifier

The readonly modifier works with type and interface properties (including class properties and class constructors). It has two usable forms:

  • readonly (which applies the modifier),
  • -readonly (which removes the modifier).

Defining constants on the class level defines the most obvious usage of aforesaid modifier, not to mention sporadic usages of non-alterable properties with values assigned only once — during object construction.

The readonly family of types

The standard library of TypeScript contains the following types that support shallow immutability:

  • the Readonly<T> type, which applies the readonly modifier to all compile-time properties of a generic type T,
  • the ReadonlyArray<T> type, which serves as an interface for providing readonly arrays of a generic type T,
  • the ReadonlySet<T> type, which serves as an interface for providing readonly sets of a generic type T,
  • the ReadonlyMap<K, V> type, which serves as an interface for providing readonly maps with keys of a generic type K and values of a generic type V.

Even thought it appears that e.g. Readonly<T> acts as a type equal to ReadonlyArray<T> due to certain optimisations embodied into the TypeScript compiler, I would always advise to choose the dedicated (latter) type, especially when extracting the generic type using the infer keyword at a later point. I find it important to note that the the Object.freeze<T> method returns the Readonly<T> type, which provides a bridge between the concept of freezing objects in JavaScript and the concept of readonly types in TypeScript.

Deep immutability

As mentioned before, the readonly modifier does not enforce deep immutability, therefore a developer should use either a third-party library to achieve that, or write a custom implementation. I can recommend the type DeepReadonly, implemented as part of the ts-essentials library. I would not advise investing time into building type definitions for deep immutability from scratch, unless the developer fully understands many intricacies of the type system of TypeScript.

Guidelines for Immutability

Applying immutability to one object within a project provides little to none measurable added value for no developer, therefore immutability requires integration into the core architectural principles of the project in question. To what degree software professionals should enforce such a pattern remains open to a debate, and I have acquired my own believes on this topic during my career. I collected the following guidelines for TypeScript development based on my own experiences with many contemporary languages, taking into account my long journey with functional programming:

  • developers should declare variables with the const keyword (already explained beforehand),
  • developers should use only on the compile-type immutability guarantees (already explained beforehand),
  • developers should restrict the usage of a class instance to the very function that constructs it,
  • developers should declare immutable types for global usage with the Readonly<T> wrapper types (with or without mutable type helpers),
  • developers should declare mutable types for local usage by extracting the proper subtypes from the immutable types (or use mutable type helpers, whichever easier),
  • shallow immutability provides all necessary functionality to effectively enforce deep immutability,
  • functions should expect immutable parameters (object and primitive alike),
  • functions should return immutable values,
  • ideal functions work in a pure fashion, with no side effects.

To provide an educated explanation for the collected points above, I shall start with containing the usage of class instances within the methods that create them. The very concept of classes does not really align with functional-programming concepts like immutability and function purity, however, I cannot dissuade anybody from using object-oriented patterns, because of the value they bring to the table. Therefore, in order to maintain readability in code, if possible, developers should construct class instances (or prototype instances) and use them within one function without passing them to other functions.

Declaring immutable types for global (project-wide) space allows developers to think with immutability in their minds first, and ensures that creating mutable types happens explicitly in a limited number of scenarios — if necessary, developers could use helper types to construct more complex types. Most functions in the system ought to accept and produce unchangeable structures. In order to show the reader how to build mutable types from immutable ones, I defined utility types for such transformations, available down below:

Utilities for extracting generic types from readonly types

With the right mindset, using shallow-immutability types should allow developers to achieve the same results as wrapping each type in the DeepReadonly wrapper type. I could also argue that using deep immutability everywhere in code forces the compiler to perform more complex calculations every time a highly-referenced type gets changed, which might reduce the seamlessness of operations in some IDEs. In the end, the type system does not need to shield developers from mistakes, as long as the said developers develop their code in a defensive way.

Pure functions (ones that produce no side effects) serve as one of the most important characteristics of functional programming. In order to enforce purity already on the type level, developers ought to pass immutable structures to such functions, type their return types as unchangeable ones and refrain from modifying global variables or executing globally-accessible mutable code. I wrote a snippet that sums up all the relevant findings down below:

An example of most of the guidelines for immutability in TypeScript

Refactorings

Starting new projects gives system designers many possibilities to experiment with new paradigms, such as immutability, which does not usually happen for already-existing projects. Refactoring of an old codebase might prove problematic even for seasoned developers, therefore software professionals should always plan such project-wide changes in advance. I compiled a list of ideas on how to change any TypeScript project to one that enforces immutability by specifying that developers should:

  • change the return types of the old functions to their immutable counterparts and fix the type problems,
  • change the parameter types of the old functions to their immutable counterparts and fix the type problems,
  • convert immutable structures to mutable ones using shallow copying, or, in case of failure, deep copying,
  • write new code in the immutable fashion using the aforementioned guidelines,
  • not fully trust the compiler to show all potential errors during the transition,
  • (probably) rely on extensive tests during the transition.

Making return types immutable constitutes the first step of all refactorings I have done. It requires no changes to the function in question as the compiler promotes any mutable return value into its immutable counterpart, based on the covariance rules (exactly as in the object-oriented programming, it understands wider types as narrower types). Functions that use the return values of the aforementioned functions need to:

  • shallow copy these values in order to satisfy the compiler, or
  • adjust the code in a different way.

Conversions of immutable values to their shallowly-immutable versions might happen using already available object methods defined in the standard library of JavaScript:

  • for the object obj - Object.assign({}, obj) or { ...obj },
  • for the array arr - arr.slice() or [ ...arr ].

I would advise checking the transpilation rules of the project in question, in terms of the target language and the transpiler used, before using the ... operator, as some optimisations might not result in performant code. Any developer should take into account that codebases that rely on immutability, have the potential to use a lot of shallow copying.

Promoting function parameter types to their immutable versions serves as the second step. Inversely towards the first step, this change does not affect the code outside the functions in question (thanks to the aforementioned rules of covariance). I wrote a snippet that shows how to refactor a function that uses mutable parameters, available underneath:

An example of a refactoring that supports immutability concepts in TypeScript

Due to performance reasons, developers should refrain from deep copying, especially when involving massive objects. If there exists a function that accepts an object and mutates a property of the nested object within the said object, then the following observations might apply:

  • the used data structure does not align with its usage, and then it obviously requires correction (involves a potential refactoring),
  • the function in question should expect the nested object instead of the very said object (might not solve the architectural problem that exists there).

In case the developer wants to apply changes to nested structures within a particular object, I recommend reading about the functional-programming concept of optics (namely lenses and prisms). The monocle-ts library provides an exemplary implementation of these ideas.

I find it worth noting that introducing immutability might expose previously-existing bugs in business-logic implementation, with or without the explicit help from the compiler. In any project, developers should recognize the toolchain as just a set of tools to enhance their workflow, nothing less and nothing more. I consider good products as those covered with a reasonable number of meaningful tests, which hopefully ensure a great experience on part of the end user.

Summary

In the end, I would not declare immutability as an easy concept to apply on the architectural level, especially in TypeScript. This design pattern alone provides no guarantees on creating great software, however, I do believe, based on my experiences, that it allows many developers to understand the implications and limitations of their changes due to the scoping factor of immutability. As I am a stalwart supporter of functional programming, I am a stalwart supporter of the aforementioned concept, but it does not mean it can seamlessly work with every conceivable project.

--

--