Immutable.js Records in TypeScript

Article summary

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.

Conversation
  • Ryan says:

    Awesome post, the 4th piece is exactly what I was missing.

    One addition: you can now get the static error on assignment by marking your properties as readonly on your class.

  • Chris Feijoo says:

    I just created a little library meant to do structural-sharing tree updates, with full static analysis and completion.

    It has the advantage to use Plain-Old Javascript Objects, compared to ImmutableJS Records that wrap your object and make it more difficult to handle, and do not provide great static analysis.

    You can use it very easily with Redux, and we use it everyday on our projects.

    https://github.com/kube/monolite

  • Javier Gonzalez says:

    Nice post! You might even get more typing assistance by doing this:

    “`
    export interface CParams {
    value: number;
    value2: number;
    }

    export class C extends Record({ value: 1, value2: 2 } as CParams) implements CParams {
    readonly value: number;
    readonly value2: number;

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

    with(vals: Partial): C {
    return this.merge(vals) as this;
    }
    }
    “`

  • Jeremy says:

    I think you can accomplish the same objectives without any libraries.

    interface State {
    readonly a: string,
    readonly b: string
    }

    const astate: State = { a: “high”, b: “low”}
    astate.b = “lower” // compile error
    const another = { …astate, b: “lower”}

    Am I missing anything?

    • Drew Colthorp Drew Colthorp says:

      Good point Jeremy! This post was released before spread syntax was added in TS 2.1. Using that combined with readonly properties is probably a better default at this point.

    • Alpgiray says:

      Hi,

      You might want to read immutablejs documentations. It maps unchanged properties of former object and only assigns changed properties. So it is fast and space saving. But of course it could be cumbersome for simple solutions and your proposal works just fine.

    • Lance Finney says:

      It seems to me that this approach provides compile-time safety, but it doesn’t give any run-time safety. For runtime safety, I think you need something like Immutable.js.

  • Morgan says:

    I appreciate the article Drew. I arrived here looking for an answer to this question – perhaps you could help:

    I have a class extending a Record like yours above. But in the constructor, I also do some validation of an array (say…. do all the numbers in it add up to 100?). If not I also add a property `isValid` (a boolean).

    Now, when I want to update the object, I also want to rerun that validation logic. My first thought was to create a class from the update, but then I get all those default values, which will overwrite existing values (which I do not intend, I just have a couple fields to update).

    A second thought is to add it to your `with` function??

    What do you think?

    • Drew Colthorp Drew Colthorp says:

      Hi Morgan,

      I think running the validation in with would work. I’d consider a few other options as well.

      One potential downside about the isValid approach is that it complicates your domain model with validation logic. It may be desirable depending on your circumstance, but my default would be to avoid it.

      One useful technique for this is to create a separate type Valid<T> which is a T that has been proven to be valid. You can then define a type guard which, given some model, proves that that model is valid.

      You can then define containers and functions to take a Valid<Model> and typescript will enforce that anything stored there/passed into that function has been checked for validity.

      See an example here.

      You can elaborate on this technique to return a set of validation errors in the case of invalid objects by changing validate model to build/return some sort of failed validation summary instead of undefined.

      Drew

      • Morgan says:

        Interesting… thanks for the gist and explanation. I was unfamiliar with type guards. I’m trying to get through the rest of that gitbook.

        Here’s another consideration for this scenario. I am hoping to save the `isValid` to the object in the DB. Why? I’d like to query against it perhaps. At least use it for user analytics.

        With that in mind, I like your idea of returning (one of) a set of validation errors for invalid objects. Think you would do something like:

        if (isInvalidForReason1(someModel)){
        someModel.invalid = ‘REASON_1’;
        }

        Before sending the PUT/PATCH to the db that is.

        Thanks for you thoughts on this.
        -Morgan

  • The current rc release of immutable has much better TypeScript generic support:

    npm install immutable@rc

    import * as Immutable from “immutable”;

    interface Foo {
    id: number;
    bar: string;
    }

    const FooRecord = Immutable.Record({
    // Default values
    id: null,
    bar: “Empty (default)”
    });

    const foo = FooRecord({ id: 3 });
    foo.id; // 3
    foo.bar; // “Empty (default)”

    const bar = foo.merge({ bar: “blah” });
    bar.id // 3
    bar.bar // “blah”

  • Comments are closed.