11 Comments

Immutable.js Records in TypeScript

React apps with Redux are built around an immutable state model. Your entire application state is one immutable data structure stored in a single variable. Changes in your application are not made by mutating fields on model objects or controllers, but by deriving new versions of your application state when you make a single change to the previous version.

This approach comes with some huge benefits in tooling and simplicity, but it requires some care.

It’s certainly possible to model your Redux application state using plain old JavaScript/TypeScript objects and arrays, but this approach has a few downsides:

  1. The built-in data types aren’t immutable. There’s no real protection against an errant mutation of your application state, violating the basic assumptions of Redux.
  2. Creating copies of arrays and maps is inefficient, requiring a O(n) copy of the data.

Fortunately, Facebook has also released Immutable.js, a library of persistent immutable data structures. This library provides maps, sequences, and record types that are structured to enforce immutability efficiently. Immutable.js data structures can’t be changed–instead, each mutation creates a brand new data structure that shares most of its structure with the previous version.

Since it’s immutable, this is safe. It’s also efficient. By using fancy data structures, each change is O(log32 n), effectively constant time.

Immutable.js comes with type definitions for TypeScript’s built-in data structures. Less fortunately, creating immutable record types (e.g., classes) with heterogeneous values isn’t as straightforward as Map<K,V>.

I looked for a solution and found a few libraries, but they all were too complicated. I wanted something that:

  • Is simple and easy to understand
  • Provides typed access to properties
  • Offers a type-safe, statically enforced way to perform updates
  • Resists incorrect use, without requiring static enforcement

Example

Here’s an example of the simplest approach I could come up with:

type CounterParams = {
    value?: number,
    status?: Status,
};
export class Counter extends Record({ value: 0, status: OK }) {
    value: number;
    status: Status;

    constructor(params?: CounterParams) {
        params ? super(params) : super();
    }

    with(values: CounterParams) {
        return this.merge(values) as this;
    }
}

(Assume Status is an enumeration type with a value called OK.)

This approach supports:

  • Creation via a strongly typed constructor with defaults for omitted values
  • Statically-typed updates using the with method
  • Statically-typed property access

Immutable.js includes a bunch of other methods, such as merge, that are inherited from Record or Map and present but untyped. I don’t see these as part of the usual API for my records, and that’s OK with me. I may not get a static error if I try to set a property, but I’ll get a runtime error. That’s safe enough for me, given that the convention will be to use the with method.

Use

Here’s how you’d use one of these immutable records:

let c1 = new Counter({ value: 2 });
c1.value // => 2
c1.status // => OK
c1.foo // Type error

let c2 = c1.with({ value: 3});
c2.value // => 3

c1.with({ status: 'bogus' }) // Type error

How It Works

All of the actual functionality is inherited from the Immutable.js Record class. We’re using the type system to create a simple typed facade. There are a few key steps.

1. Define a parameter type

First, define a type that contains all of the fields you’ll have in your record. Each one is optional:

type CounterParams = {
    value?: number,
    status?: Status,
};

Since Counter will have a value and a status, our CounterParams type has optional entries for both of those. This is an object type that may have up to a value of type number and a status of type Status, but it may be missing either or both of those.

This would be the logical type of the Immutable.js constructor argument and update method, if we’d written it from scratch. Since we’re inheriting the implementation from JavaScript, it doesn’t have more detailed type information.

2. Inherit from Record

Next, we define our class and inherit from Record:

export class Counter extends Record({ value: 0, status: OK }) {

There’s nothing special here. This is just straight-up, normal, untyped Immutable.js record type definition with zero type help. We’re just providing default values for value and status to the Record class creation function.

3. Type the constructor

Now, we define our constructor to take an optional CounterParams argument and delegate to super. If we construct our object with no argument, it gets all default values. If we construct it with params, we end up overriding just the supplied arguments:

    constructor(params?: CounterParams) {
        params ? super(params) : super();
    }

Note that we don’t call super with undefined to get the proper default behavior.

4. Define with

Our with method is just a typed alias for the merge method inherited from Immutable’s Map type. Unfortunately, merge won’t statically enforce our type signature for parameters, and is typed to return a Map. We use CounterParams as our type argument to solve the former issue. We cast as this to solve the latter, which works because merge does, in fact, return an instance of our type–it just doesn’t specify that in its signature at the time of writing this. Using as this convinces TypeScript that with will, in fact, return a Counter.

    with(values: CounterParams) {
        return this.merge(values) as this;
    }

And that’s it! A little repetitive, but simple and easy to understand. And we now have nice, statically typed persistent immutable model types.