Immutable data classes in TypeScript

Immutable data classes are a key concept that we use extensively in our templates (including the background and typography templates), as well as in smaller projects like the music visualizer.

We’ll start by looking at the drawbacks of mutable classes, then explore the benefits of immutability, and finally, walk through how to implement these classes.

In the examples, we'll examine classes that represent specific data structures, such as an object holding a coordinate (Vector2) or a Circle class containing the necessary information for rendering.

Drawbacks of mutable classes

The main drawback of mutable classes is that they make code difficult to understand and reason about. It becomes challenging to track which function modified an object, making the code harder to read and follow.

Let's take a look at the following mutable Vector2 class:

class Vector2 {
    x: number
    y: number

    constructor(x: number, y: number) {
        this.x = x
        this.y = y
    }
}

Suppose we add a method to the Vector2 class that adds another Vector2 to the current instance:

add(other: Vector2): Vector2 {
    this.x += other.x
    this.y += other.y
    return this
}

From a maintenance perspective, this approach is problematic. It's difficult to understand how the function operates just by looking at its signature:

function add(other: Vector2): Vector2

A key question arises: does this method return a new Vector2, or does it modify the current instance (this) internally? The signature alone doesn't make this clear.

Let's take this hypothetical example where an offset is added to itself twice. One might expect the result to be 3 * offset, since we're applying the offset twice. However, because the add method modifies the current instance (this), the first addition changes offset to (2, 2). The second addition then modifies it to (4, 4), resulting in an unexpected outcome. This illustrates how mutability can lead to unpredictable state changes, making the code harder to reason about and prone to bugs.

test("unexpected result", () => {
    const offset = new Vector2(1, 1)

    const result = offset
        // Apply the offset twice
        .add(offset) // Adding (1, 1)
        .add(offset) // Adding ?

    expect(result.x).toEqual(4)
    expect(result.y).toEqual(4)
})

Benefits of Using Immutable Classes in TypeScript

The structure of immutable classes follow the following pattern:

  1. Every field in the object is readonly
  2. At least one constructor that initializes all fields
  3. All operations return a new instance
  4. (Optional) Convenience methods to easily copy
class Vector2 {
    // 1) Every field in the object is `readonly`
    readonly x: number
    readonly y: number

    // 2) At least one constructor that initializes all fields
    constructor(x: number, y: number) {
        this.x = x
        this.y = y
    }

    // 3) All operations return a new instance
    add(other: Vector2): Vector2 {
        return new Vector2(
            this.x + other.x,
            this.y + other.y,
        )
    }

    // ... other methods ...

    // 4) (Optional) Convenience methods to easily copy
    //     Expects an object for named arguments
    copy({
        x,
        y,
    }: {
        x?: number
        y?: number
    }): Vector2 {
        return new Vector2(
            typeof x === "undefined" ? this.x : x,
            typeof y === "undefined" ? this.y : y,
        )
    }
}

Drawbacks of immutable classes

The main drawback of immutable classes in TypeScript is the potential performance cost associated with creating new instances. However, in most cases, the benefits of using immutability far outweigh this minor disadvantage. It's generally best to consider such performance optimization only after your code is fully functional, and you need to maximize efficiency. Even then, switching from immutable to mutable classes does not guarantee a performance improvement.

Summary

This short article covered how using immutable classes can significantly enhance code maintainability and provide a simpler approach to writing code.