Friday, January 05, 2024 · 11 min read
javascript
typescript

Understanding Immutability in JavaScript

An enduring concept that simplifies complexity

1*clwxZI7DNkkMbcMqYBge2A.webp

Programming is an ever-evolving field, with many concepts maintaining their relevance through time. Among these, ‘immutability’ stands out. This concept, which emphasizes that once data is created, it should not be changed, may appear contrary to the dynamic nature of programming. However, when used effectively, immutability offers considerable benefits. JavaScript, with its flexible paradigms and supportive libraries, offers numerous ways to utilize and benefit from immutability.

During a recent code review for a project I’m working on, I noticed an issue related to direct data mutation. This incident underscored the potential complications mutable data can cause and highlighted the importance of immutability. Motivated by this experience, I dived deeper into the subject. The purpose of this blog post is to share the insights I’ve gathered on the application and advantages of immutability in JavaScript.

Understanding Data Types in JavaScript

To understand the full scope of immutability, we first need to explore the two primary types of data in JavaScript: primitive and reference types.

Primitive Types

JavaScript has seven primitive types: string, number, boolean, null, undefined, symbol and BigInt. These types are considered immutable, meaning once a primitive type variable is created, its value can never be changed.

Consider the following example:

let greeting = "Hello, I am a human!";
greeting = "Hello, chipmunk!";

In this example, it might seem like we’ve changed the string, greeting. In reality, we've discarded the old string and created a new one. The original string, "Hello, I am a human!", remains as it was, proving the immutable nature of primitive types.

Reference Types

In contrast to primitive types, reference types (object, array, function, etc.) in JavaScript are mutable. While the reference itself (the link to the memory address where the data is stored) cannot be changed, the properties of these data types can be altered.

For instance:

let computer = { brand: "Apple" };
computer.brand = "LG";

Here, we’ve changed the brand property of the computer object, which wouldn't be possible if JavaScript objects were immutable. This underlines the mutable nature of reference types.

Understanding Immutability

JavaScript leverages immutability in a variety of ways, which plays a crucial role in web development. For instance, consider adding an item to a list. In a mutable context, you might directly insert the new item into the existing list:

let arr = [1, 2, 3];
arr.push(4); // arr is now [1, 2, 3, 4]

In contrast, an immutable approach would create a new list, leaving the original untouched. This can be achieved using either the concat method or the ES6 spread operator:

const arr = [1, 2, 3];
const newArr1 = arr.concat(4); // newArr1 is [1, 2, 3, 4], arr remains [1, 2, 3]
const newArr2 = [...arr, 4]; // newArr2 is [1, 2, 3, 4], arr remains [1, 2, 3]

Objects, too, can be handled in an immutable manner. Rather than altering an existing object directly, we create a new one:

let obj = {a: 1, b: 2};
obj.c = 3; // obj is now {a: 1, b: 2, c: 3}

// vs.

const obj = {a: 1, b: 2};
const newObj = {...obj, c: 3}; // newObj is {a: 1, b: 2, c: 3}, obj remains {a: 1, b: 2}

Unpacking Immutability

Immutability signifies the unchanging nature of an entity. In programming, it refers to the state of data or an object remaining constant after its initial creation. This concept is especially important when working with data structures that are inherently immutable.

For instance, strings in JavaScript are immutable. Even when it appears as if you’re modifying a string, what you’re really doing is creating a new one:

let greeting = "Hello, world!";
greeting = "Hello, Panda!"; // Creates a new string, doesn't change the original

When we reassign greeting, we're not changing the original string, "Hello, world!". Instead, we're creating a new string, "Hello, Panda!", and greeting now points to this new string. The same principle can be applied to arrays:

let numbers = [1, 2, 3, 4, 5];
let newNumbers = [...numbers, 6]; // Creates a new array, leaves 'numbers' unaltered

In the example above, newNumbers is a new array derived from numbers. The original numbers array remains unchanged.

Immutability isn’t just a syntactic choice — it’s a mindset that permeates your code. Its importance might not be immediately evident with simpler data types. Still, as we work with larger, more complex data structures, immutability becomes pivotal, affecting the predictability, performance, and debugging of your code. It helps prevent elusive bugs and results in cleaner, easier-to-understand code.

The Benefits of Immutability in JavaScript

Immutability extends beyond being a theoretical principle; it offers tangible benefits in JavaScript programming. Let’s explore this in greater detail:

1️⃣ Predictability

Immutable data leads to reliable and consistent results, simplifying the tracking of changes and making it easier to understand your application’s state at any point. Since data can’t be changed once it’s created, there’s no need to worry about unforeseen surprises meddling with your data. This increased predictability makes your code more readable and easier to debug.

Consider a scenario where we have two functions: one to add a book to a list of books in a mutable way, and another in an immutable way. Now imagine a case where an unexpected bug in your code leads to the addBookMutably function being called multiple times, adding several copies of the same book. However, with the immutable version, you could implement a check to ensure a book isn't already in the array before adding it, preventing duplicate entries:

const books = ["Steve Jobs", "12 Rules for Life"];

// Mutable version
function addBookMutably(book) {
  books.push(book);
}

addBookMutably("Don't Make Me Think");
console.log(books); // Output: ["Steve Jobs", "12 Rules for Life", "Don't Make Me Think"]

// Immutable version
function addBookImmutably(books, book) {
  if (!books.includes(book)) {
    return [...books, book];
  } else {
    return books;
  }
}

const newBooks = addBookImmutably(books, "Hooked");
console.log(newBooks); // Output: ["Steve Jobs", "12 Rules for Life", "Don't Make Me Think", "Hooked"]

The mutable version directly alters the books array, whereas the immutable version returns a new array if the book isn't already in the array, leaving the original books array unaltered. This is much safer and predictable because it doesn't change the state of your existing data, thus minimizing the risk of bugs in your code.

💡 Elaboration: Immutable data acts as a source of truth, enabling developers to trace back the root cause of a problem in the application. With mutable data, by the time a bug surfaces, the original data state might have already changed, making it challenging to identify the bug’s origin.

2️⃣ Performance Optimization

Immutability can lead to more efficient code due to the nature of memory storage and retrieval in JavaScript. Many JavaScript frameworks and libraries are optimized to handle immutable data more efficiently, thereby improving your application’s overall performance.

A common example is the use of persistent data structures, which use structural sharing to prevent copying the entire object or data when a small change is made. In other words, the parts of the data that haven’t changed can be reused across different versions, leading to memory efficiency.

However, it’s important to note a potential trade-off: the benefits of using immutability for optimization need to be balanced against the cost of creating new copies of data. Creating a new copy of a large data structure for every minor change can lead to increased memory usage and negatively affect performance. Understanding when and where to use immutability can be a crucial aspect of writing efficient code.

const list1 = [1, 2, 3, 4, 5];
const list2 = [...list1, 6]; // Creates a new copy of 'list1' and adds '6' to it

In the example above, although list2 only adds one new item to list1, it still requires creating a whole new array, which can be costly if list1 is large.

💡 Elaboration: Consider drawing a picture: replicating certain unchanging parts saves time. Similarly, JavaScript engines can skip unnecessary operations with immutable data, enhancing performance and memory efficiency.

3️⃣ Concurrency Control

While JavaScript runs on a single thread, its asynchronous nature can sometimes result in operations occurring out of sequence, which can create conditions similar to multithreading. Immutability can help to prevent such issues. Since immutable data can’t be changed once created, you avoid the possibility of a function mutating data while another function is still using it.

let user = { name: "John", age: 25 };

function updateUser(user) {
  return { ...user, age: user.age + 1 };
}

// Another part of the code
setTimeout(() => {
  console.log(user.age); // Always prints 25
}, 1000);

// Elsewhere in the code
user = updateUser(user); // Doesn't affect the setTimeout function's output

In this example, updateUser doesn't modify the user object, but instead creates a new one. This leaves the original user object preserved, ensuring it doesn't affect the output of the setTimeout function.

💡 Elaboration: In JavaScript’s single-threaded but asynchronous environment, shared mutable data can lead to inconsistencies or bugs. Immutable data, like a steadfast sketch for a mural, ensures that all operations reference a consistent ‘snapshot,’ maintaining data integrity despite concurrent operations.

4️⃣ Easier Undo/Redo and Time Travel Debugging

Immutable data simplifies the implementation of features like undo/redo, as this process involves keeping track of previous states. JavaScript frameworks like Redux leverage this advantage, allowing you to step through different states of your application, making debugging easier and more intuitive.

💡Elaboration: Imagine data flowing like a river through your application. If we ensure the water (data) remains unaltered (immutability), we can easily track its path. Any changes in data create a new branch rather than altering existing ones, thus simplifying data flow and manipulation.

Immutability’s Role in JavaScript Libraries

Immutability remains a cornerstone principle in several JavaScript libraries, playing a significant role in their functionality and efficiency.

React

React, a widely used JavaScript library for building user interfaces embraces immutability in its state management. It does so because immutability allows React to keep track of state changes more accurately, leading to efficient re-rendering of only the necessary components and improving performance. In a mutable world, React would have to deep compare objects to determine changes, which can be a slow process, especially with large objects.

import React, { useState } from 'react';

function CounterComponent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>
        Click me
      </button>
    </div>
  );
}
export default CounterComponent;

In this case, setCount creates a new state instead of changing the existing count. This adherence to immutability allows React to effectively detect changes and re-render the component accordingly.

Redux Toolkit and Immer

Redux Toolkit, the official, opinionated, batteries-included toolset for efficient Redux development, incorporates immutability in a more friendly way by using the Immer library under the hood. Immer provides a more convenient way to create the next state by allowing developers to write code as if they were mutating the current state. It operates by efficiently creating a new copy of the state, leaving the original state untouched:

Here’s an example of a Redux Toolkit slice:

import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: (state) => state + 1,
    decrement: (state) => state - 1,
  },
})
export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer

In this example, even though it looks like the increment and decrement functions are directly modifying the state, it's actually creating a new state in an immutable way thanks to Immer. This way, Redux Toolkit offers the best of both worlds: the simplicity of mutable code with the benefits of immutability.

Zustand

Zustand is a minimalist state management library in React that uses hooks. It emphasizes immutability, which helps prevent unwanted side effects, leading to a more predictable code.

import { create } from 'zustand';

const useChipmunkStore = create((set) => ({
  chipmunks: 0,
  squirrels: 5,
  increaseChipmunks: () => set((state) => ({ chipmunks: state.chipmunks + 1 })),
  increaseSquirrels: () => set((state) => ({ squirrels: state.squirrels + 1 })),
}))

// 🐿️ more chipmunks and squirrels!

Here, the increaseChipmunks and increaseSquirrels functions take the current state, increment the appropriate value, and return the new state. Zustand will merge the returned object into the current state in an immutable manner, leading to predictable updates.

Conclusion

Understanding immutability and its importance is crucial for any developer, particularly those working with JavaScript and its associated libraries. It plays a significant role in how we handle data, providing benefits like more predictable code, easier debugging, and more efficient execution. So, next time you’re about to directly mutate a piece of data, pause and think about the potential benefits of an immutable approach. Until next time!

☝One More Thing️

While we’ve been exploring the advantages of immutability in the context of JavaScript, the addition of TypeScript’s type system amplifies these benefits further. TypeScript, being a statically typed superset of JavaScript, enhances our capacity to enforce immutability. It accomplishes this through tools like the readonly modifier, the as const assertion, and utility types like Readonly and ReadonlyDeep.

TypeScript’s Readonly and as const

In TypeScript, readonly is a keyword that can be used to mark the properties of a type as unchangeable. This means that once a property is set, it cannot be modified, enforcing immutability. Let's observe this in action:

type Avenger = {
  readonly name: string;
};

const avenger: Avenger = {
  name: "Iron Man",
};

// This will result in a TypeScript error 😬
avenger.name = "Spider Man";

In this example, the name property of the Avenger type is marked as readonly, preventing it from being changed after initialization. Attempts to change the name property will cause a TypeScript error.

On the other hand, TypeScript provides as const - a unique way to create a deeply immutable object. With as const, both the variable and all its properties are read-only:

const avengerPowerMap = {
  'ironMan': 'Missile projection',
  'spiderMan': 'Healing Factor',
  'thor': 'Energy Manipulation',
} as const;

// This will result in a TypeScript error 😬
avengerPowerMap.ironMan = 'Speed';

In this scenario, the avengerPowerMap object is declared as a deeply immutable object using as const. Thus, trying to mutate any property will throw a TypeScript error.

TypeScript’s Readonly and ReadonlyDeep

TypeScript also extends immutability enforcement to collections through utility types like Readonly and ReadonlyDeep.

The Readonly type produces an immutable version of an array or object, preventing additions, removals, and modifications at the top level:

type Avengers = {
  name: { fullName: string };
  weapons: { name: string; description: string }[];
}[];

const avengers: Readonly<Avengers> = [
  {
    name: {
      fullName: "Iron Man",
    },
    weapons: [
      {
        name: "Repulsor Rays",
        description: "Pew! Pew! Boom!",
      },
    ],
  },
  {
    name: {
      fullName: "Spider Man",
    },
    weapons: [
      {
        name: "Web Shooters",
        description: "Shooter's gonna shoot!",
      },
    ],
  },
];

// This will result in a TypeScript error 🐿️
avengers.push({
  name: {
    fullName: "Hulk",
    weapons: [
      {
        name: "Hands",
        description: "Smash time!",
      },
    ],
  },
});

In this instance, avengers is an array marked as Readonly, which means it's immune to any additions or removals. However, Readonly does not prevent modifications to the objects within the array.

To ensure deep immutability, TypeScript has libraries like type-fest which provides the ReadonlyDeep type. This ensures all properties, including those nested, are read-only:

import { ReadonlyDeep } from 'type-fest';

type Avenger = {
  name: {
    fullName: string;
  };
};

const avenger: ReadonlyDeep<Avenger> = {
  name: {
    fullName: 'Iron Man',
  },
};

// This will result in a TypeScript error 😬
avenger.name.fullName = 'Spider Man';

Here, the ReadonlyDeep utility type ensures all properties within the avenger object, including nested ones, are read-only. Any attempts to modify these properties will result in a TypeScript error.

In a nutshell, TypeScript provides an extensive toolkit for enforcing immutability at varying depths and complexities. These tools help you to uphold the principles of immutability more effectively in your code. By adhering to the principle of immutability, we can improve the predictability and readability of our code, and manage the application state more efficiently.