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:
- Every field in the object is
readonly
- At least one constructor that initializes all fields
- All operations return a new instance
- (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.