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.