The hunt for an immutable, type safe data record in JavaScript

Ever since working with Scala’s case classes I was hooked on the idea of having a type safe data record that was also immutable. What’s not to like? It’s type safe and immutable (duh). So I wanted to see if I can get the same thing in JavaScript - the most mutable and dynamic language known to man.

class Person {
  givenName;
  familyName;
}

This will serve as our starting point: a simple class in JavaScript. It contains two class instance fields according to the ES Class Fields & Static Properties ES7 proposal. Also, it is mutable. After an instance of Person is created, its fields can be changed.

immutable.js

Facebook’s JavaScript library immutable offers an immutable Record data type. Let’s see how far it takes us.

const Person = Record({ givenName: '', familyName: '' });

That’s how we define our record. If you ask me, it seems strange to be required to provide default values in order to define the record structure. It mixes two concerns: structure definition and default values. Consequently, we can’t require a property to be provided; there is always a default value.

Anyway, it’s nice that we can access each property directly and are not forced to use something like .get().

const dad = new Person({ givenName: 'Homer', familyName: 'Simpson' });
dad.givenName // 'Homer'

What is very surprising is that there is no runtime error when constructing a record with any additional properties.

const dad = new Person({ givenName: 'Homer', age: 40 });
dad.age // undefined

I find this quite dangerous. Maybe we can make it less problematic by adding type safety?

Type safety in JavaScript? There are currently two serious efforts to bring type safety to JavaScript: TypeScript and Flow. From what I’ve seen so far, the syntax of them is almost identical.

I’ve picked Flow for this since it seemed easier to get started with and also works well with Babel. TypeScript might work as well though.

There is an pending issue to have proper Flow type definitions for immutable but using its current state already works quite well.

const dad = new Person({ givenName: 42 });
// type error: number is incompatible with string

const dad = new Person({ givenName: 'Homer', age: 42 });
// type error: property `age` not found

That’s quite nice! We get checks for wrong data types and unknown initialisation properties.

Unfortunately, the property access doesn’t seem to get checked properly.

dad.age
// type checks OK, returns undefined

All in all, we can get quite far with Record. But it’s not perfect.

Babel

It seems we can’t reach our goal by writing code, we need to transform code. Since JavaScript doesn’t have macros to do that, we take the next best thing: a Babel plugin.

What’s Babel? Babel was born as a transpiler that takes modern JavaScript code and emits code that can run on older platforms where some of the latest feature weren’t supported yet. But it has since grown to become a more general code transformation and code generation platform.

The plugin needs to know which class to make immutable and which to ignore. So we create a new decorator to mark the class for transformation:

@Record()
class Person {
  givenName;
  familyName;
}

Our Babel plugin will look for @Record and transform the code into this:

@Record()
class Person {
  constructor(init) {
    this.__givenName = init.givenName;
    this.__familyName = init.familyName;
  }

  __givenName;
  __familyName;

  get givenName() {
    return this.__givenName;
  }

  get familyName() {
    return this.__familyName;
  }
}

Here’s what happened:

This is how we could use it:

const dad = new Person({ givenName: 'Homer', familyName: 'Simpson' });
console.log(`created dad ${dad.givenName} ${dad.familyName}`);
// will output "created dad Homer Simpson"

But it’s not terribly useful, yet. How do we change it? By creating a copy! Since this can be quite cumbersome and error-prone for larger objects, we generate a method to help us with that:

@Record()
class Person {
  // ...

  update(update) {
    return new Person({
      givenName: update.givenName || this.__givenName,
      familyName: update.familyName || this.__familyName
    });
  }
}

update gets an object and creates a new Person by using the provided data or falls back to the existing data, if none is provided.

Now we can easily create a copy of our record:

const son = dad.update({ givenName: 'Bart' });
console.log(`created son ${son.givenName} ${son.familyName}`);
// will output "created son Bart Simpson"

The next step is to make it type safe. Basically, our original code simply receives type annotations for its fields. That’s it.

@Record()
class Person {
  givenName: string;
  familyName: string;
}

The plugin now mostly just copies the new type annotations to the right places but also creates two new types:

@Record()
class Person {
  constructor(init: PersonInit) {
    this.__givenName = init.givenName;
    this.__familyName = init.familyName;
  }

  __givenName: string;
  __familyName: string;

  get givenName(): string {
    return this.__givenName;
  }

  get familyName(): string {
    return this.__familyName;
  }

  update(update: PersonUpdate): Person {
    return new Person({
      givenName: update.givenName || this.__givenName,
      familyName: update.familyName || this.__familyName
    });
  }
}

type PersonInit = {
  givenName: string;
  familyName: string;
};

type PersonUpdate = {
  givenName?: string;
  familyName?: string;
};

The first one, PersonInit, defines the type of the object used to initialise the record. The second one, PersonUpdate, defines the type of the object used to create a copy of the record. It is important to notice that it contains optional properties (marked by ? at the end). This means that the client doesn’t have to specify any of them.

We now a have a type safe, immutable record.

new Person({ givenName: 'Homer', familyName: 'Simpson' });
// OK

new Person({ givenName: 'Homer' });
// type error: property `familyName` not found

new Person({ givenName: 'Homer', familyName: true });
// type error: boolean is incompatible with string

new Person({ givenName: 'Homer', familyName: 'Simpson' }).update({});
// OK

Unfortunately, the type checker does not fail when providing unknown properties during initialisation or update. This means we could easily have a typo for an optional field and wonder why nothing happens.

const daughter = dad.update({givnam: 'Lisa'};
// OK

Apparently, this is a known limitation in Flow. The workaround is to seal the object type by adding a ‘catch-all property’ with a void type.

type PersonInit = {
  givenName: string;
  familyName: string;
  [key: string]: void;
};

type PersonUpdate = {
  givenName?: string;
  familyName?: string;
  [key: string]: void;
};

Now when we use an invalid property key, it fails.

const daughter = dad.update({givnam: 'Lisa'};
// type error: string is incompatible with undefined

This admittedly slightly cryptic error message now tells us that givnam is not part of the update data type.

By the way, to make this type check in Flow the configuration file .flowconfig needs the flag unsafe.enable_getters_and_setters=true to process the getters.


The source code for this code generator can be found on GitHub

Stephan Behnke

Software developer by trade. Most of the time on the ever lasting quest for simplicity, elegance and beauty in code. Or just getting stuff done in-between.

comments powered by Disqus