Immutable.js Records with Typescript Classes- Part 1

Bunker's first technology blog post written by our Development Team

Developing a modern web application in javascript or Typescript with immutable objects is a very good practice. However, writing code that also uses ES6 class syntax and Typescript type hierarchies usually requires some major trade-offs. This series of articles will show how we decided on an approach to the problem at Bunker to get the best of both Typescript and immutability!

In these articles I’m going to assume you know what immutability is, and why it’s a good idea to use. If not, here's a great article to get you started!

I love the Immutable.js Record classes. Why? First, they do an excellent job of enforcing object shape at both creation and usage in an application. When you create a Record, it will use default values for any properties not specified, and it will ignore any properties that weren’t part of the original record definition:

const Person = Record({firstName: 'John', lastName: 'Doe'}); // default values
const myPerson = new Person({firstName: 'Jane'});
myPerson.firstName; // Jane
myPerson.lastName; // Doe

const myPerson = new Person({firstName: 'Jane', age: 22});
myPerson.age; //undefined

One of the awesome benefits is that it's easy to create a lot of Records that look fairly similar with minimal duplicated code, and it also ensures that every instance of a particular Record has the same shape.

At Bunker we’re developing a modern web application using Angular and TypeScript. As part of the application design we’re using TypeScript classes in an object-oriented model in our front end code. These classes give us the benefit of strongly typing a large amount of our front end code, which in turn can alert us to potential type-related errors during development (especially when paired with a great IDE, like VSCode). However, combining TypeScript classes, inheritance, and immutable records was not as easy as we hoped.

What to do? We decided to write our own version of an Immutable.JS Record that still allows for inheritance.

Since we didn’t feel like recreating the full functionality of an Immutable.JS Record, we had five main priorities for this new BunkerRecord:

  1. Immutable, i.e. no easy setters for properties
  2. Easily discoverable and strongly-type accessors (we like Intellisense….)
  3. Easy conversion to a plain-old-javascript object (useful for debugging, sending data via JSON web requests, etc)
  4. Supports inheritance
  5. Like the existing Immutable.JS Record, you should be able to specify as many or as few properties as you’d like during object construction, and the record will use default values for the rest

We started by making a base abstract class that stored all the underlying data in an Immutable Map. This gave us access to all the nice optimizations used with Immutable.JS data structures under the hood, as well as truly Immutable data (priority 1):

import { Map as ImmutableMap } from 'immutable';
import * as lodashForEach from 'lodash/forEach';

export abstract class BunkerRecord {
  _data: ImmutableMap<any, any> = ImmutableMap<any, any>();
}

Next, we added a constructor where we could pass in default values and create some accessors using the pretty nifty Object.defineProperty method:

constructor(initialValues?: any) {
  if (defaults) {
    this._data = this._data.merge(initialValues);
    lodashForEach(initialValues, (value, key) => {
      Object.defineProperty(this, key, {
        enumerable: false,
        get () {
          return this._data.get(key);
        },
        set () {
          throw new Error('Cannot set on an immutable record.');
        }
      });
    });
  } else {
     this._data = ImmutableMap<any, any>();
  }
}

The initialValues parameter passed into the constructor is the full set of all properties for a given object (including parent properties and child properties in an inheritance setup). Initially, an Immutable Map is created with all of the values.

Object.defineProperty creates a virtual property with a getter and setter we define. In this case, we passed the getter through to the internal Immutable.JS Map, and we threw a runtime exception if someone tried to call the setter (helping with priority 1). (Later we’ll discuss why that shouldn’t ever happen if you set up your child classes appropriately, but it’s good to program defensively)

Since we used an internal Immutable.JS Map, it was trivial to satisfy priority number 3 - making a simple JavaScript Object. We defined a method like this:

  toJS() {
      return this._data.toJS();
  }


We also were able to make a simple equals method for quick comparison.

  equals(otherRecord: BunkerRecord) {
      return typeof this === typeof otherRecord &&  
                    this._data.equals(otherRecord._data);
  }

Note that since this._data.equals is an Immutable.JS equality check, it is super fast, just instance equality.

That’s all for this article! Next time we’ll discuss how to make changes to these BunkerRecord objects, as well as start talking about type hierarchies!