Thursday 14 April 2016

Making Immutable.JS objects easier to work with in TypeScript

UPDATE 4/4/2018: Want to see how this is done in TypeScript 2.8? Click here.

I've been playing with React.JS a lot lately (personally and at work), in combination with TypeScript, Redux and Immutable.JS.

In my adventures with this particular front-end stack (starting from this excellent React/TypeScript/Redux starter template), I've been finding the use of Immutable.JS greatly helps in reasoning about code and how state changes flow through various React components.

One of the problems however, is that the Immutable.JS APIs are very dictionary-like (ie. Magic strings galore!) when dealing with immutable objects. The TypeScript definition for this library is also somewhat restrictive with regards to immutable objects, insisting that your immutable objects (ie. Maps) have keys and values of a single certain type.

Which means, if you had a TypeScript object modeled like this:


interface IGeographicCoordinate {
    lat: number;
    lng: number;
}

interface IPlacemark {
    id: number;
    coordinate: IGeographicCoordinate;
    name: string;
}

It's immutable form would look like this:


import { fromJS, Map } from 'immutable';

...

let pm: IPlacemark = {
    id: 1,
    coordinate: {
        lat: 0.0,
        lng: 0.0
    },
    name: "Null Island"
};
//First way: using fromJS
let imPlacemark = fromJS(pm);
//Second way: using Map()
let imPlacemark2 = Map(pm);

Now if we were to have a peek at what the inferred types are in any editor that supports TypeScript (for example, Visual Studio Code), we see that the default tooling experience is ... not very helpful



The first immutable object is inferred to be of type 'any'. The second one is inferred to be of type 'Map<{}, {}>', which is close enough to 'any'. If you're working with objects of type 'any', you're pretty much back in vanilla JS land with none of the benefits that TypeScript offers. If an object's type or shape can be described in TypeScript, it should!

Sadly, the typings in Immutable.JS demand our Map instances use homogeneous key and value types for all possible properties. Having keys and values as of type 'any' doesn't really help us in the strongly-typed land of TypeScript. 'any' is pretty much an escape hatch type to allow for interoperability with existing JavaScript code/libraries, it offers zero value in terms of type-safety and tooling experience.

Not to mention, accessing values of such an immutable object is magic strings galore.


let id = imPlacemark.get('id');

And because we're dealing with 'any', that propagates down to everything.



When you have nested objects, you're pretty much back in JavaScript land where any object can be anything. TypeScript can't help us here. You have to know up-front what types you're expecting because the TypeScript tooling and language services won't be able to help you.


//Returns Immutable.Map and not IGeographicCoordinate
let coord = imPlacemark.get('coordinate');
//These will be any and you have to know to get on 'lat' and 'lng' keys
let lat = coord.get('lat');
let lng = coord.get('lng');

So is there anything in TypeScript that allows us to work with APIs such as Immutable.JS Maps in a more rigid and robust manner? Fortunately, I've found a useful little pattern that I've been using that makes working with immutable objects more TypeScript friendly and it involves leveraging 2 language features of TypeScript:
With these two features, we can define a complementary (and mostly type-safe) immutable version of any interface that will return different types based on the name of the key we pass in. So if we return to our placemark example.


interface IGeographicCoordinate {
    lat: number;
    lng: number;
}

interface IPlacemark {
    id: number;
    coordinate: IGeographicCoordinate;
    name: string;
}

We can define complementary immutable versions of the above interfaces like so:


type GeographicCoordinateProperties = "lat" | "lng";
type PlacemarkProperties = "id" | "coordinate" | "name";

interface IGeographicCoordinateImmutable {
    get(key: GeographicCoordinateProperties): any;
    get(key: "lat"): number;
    get(key: "lng"): number;
}

interface IPlacemarkImmutable {
    get(key: PlacemarkProperties): any;
    get(key: "id"): number;
    get(key: "coordinate"): IGeographicCoordinateImmutable;
    get(key: "name"): string;
}

How this works is as follows:
  • We define string literal types for each interface that contains the names of all the properties of that interface.
  • In each complementary immutable interface, the get() method that returns 'any' is just a signature that closely matches the get() method of the original Immutable.JS Map. Because interfaces in TypeScript are just a compile-time means of enforcing and validating an object's "shape", it doesn't have to precisely match 1:1 with get() in Map. It just has to take a key and return some value, this interface type information disappears once everything is transpiled to JavaScript. This method signature is just the proverbial "foot in the door" to allow us to define specialized overload signatures off of it, we don't actually use this signature. By having the key be of a string literal type, it constrains our specialized overload signatures to only property names of the actual interface.
  • Then it is just a case of filling out each specialized get() with the expected return type for the given property name. If a property is another nested object, you basically return its "immutable" equivalent interface.
With this pattern, you can safely "cast" your Immutable.JS maps into type-safe immutable versions of your original interfaces with full TypeScript tooling assistance.



And you will be prevented from accessing values with unknown keys due to the string literal type constraint.



Now, unfortunately there is still one problem with this approach. We're still using magic strings for property access (albeit, constrained to a specific set of string values), meaning this is not resilient against rename or other structural reorganisation refactorings (you'll have to manually update any changed property names in the immutable interfaces), which is why I am so hoping that TypeScript gets something like a nameof operator in a future release. Having a nameof-like language construct should mean the death of most magic strings in your codebase, and would make this immutable interface approach completely type safe and refactoring-friendly. 

Still, the above pattern in its current form has greatly simplified my usage of Immutable.JS objects in TypeScript, which itself has already simplified the building of React applications and components.

Hope this is useful to you as it was for me.

6 comments:

Unknown said...

Very nice. But somewhat incomplate as I don't see anything about setting a value.

How would you do set for the map

map.set('stringkey' 'stringVal' ) ??

Thanks

Unknown said...

Very nice. How would you do set for the map

map.set('stringkey' 'stringVal' ) ??

Unknown said...

How would you use booleans.

Jackie Ng said...

Re: Does this work for set() ?

Sadly I think not (at least not as clean as get()). Your "foot in the door" set() method needs a known type for the value part, and if you're expecting different data types for various keys, then that means you almost always have to use either:

set(key: "foo" | "bar" | "baz", value: any): IYourImmutableObjectType;

or

set< T >(key: "foo" | "bar" | "baz", value: T): IYourImmutableObjectType;

But that would nullify any type safety on the value parameter for any specialized overloads of set() for "foo", "bar" and "baz" as you can pass in anything for the value as it will always resolve to the "foot in the door" set() method by the TS compiler should it not match the value parameter type in your specialized overload.

Still, at least you're still constrained to a certain set of keys for set(), so passing any key not "foo", "bar" or "baz" will be a TS error so not all is lost, but it's not as rosy as the get() case.

engineforce said...

You may also check out https://github.com/engineforce/ImmutableAssign, which is a lightweight immutable helper that supports full TypeScript type checking, and allows you to continue working with POJO (Plain Old JavaScript Object).

It supports optional [deep-freeze](https://www.npmjs.com/package/deep-freeze) to freeze input and output, which can be used in development to make sure they don't change unintentionally by us or the 3rd party libraries.

E.g.,

```
var iassign = require("immutable-assign");
var _ = require("lodash");

var nested1 = { a: { b: { c: [1, 2, 3] } } };


// 4.1: Calling iassign() and _.map() to increment to every item in "c" array
var nested2 = iassign(
nested1,
function (n) { return n.a.b.c; },
function (c) {
return _.map(c, function (i) { return i + 1; });
}
);
```

Unknown said...

I created a TypeScript library to deal with this.

It also does stuctural-sharing, and provides full static analysis and completion, by using accessor functions.

It also does not wrap your objects, simply provides functions to manipulate your states.

It works very well with Redux, and we use it in all our projects.

https://github.com/kube/monolite