Wednesday, 28 March 2018

Making Immutable.JS objects easier to work with in TypeScript: TypeScript 2.8 edition

TypeScript 2.8 was released today and one of the new touted features is conditional types.

With the introduction of condition types, it was worth revisiting an older post of mine about making immutable.js easier to use in TypeScript and see how one would solve this problem with TypeScript 2.8.

For reference, consider these interfaces:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface IGeographicCoordinate {
    lat: number;
    lng: number;
}

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

At the time of that post (this was before the revolutionary TypeScript 2.0 release), this was the best I could do to work with the immutable.js versions objects that adhered to the shape of the above interfaces.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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;
}

Notice we had to manually write "immutable" equivalents of every interface and we didn't have constructs like keyof to auto-deduce all the allowed property names. We also had to manually spell out all the specific return types for each property name due to mapped types not existing yet at that point in time.

With TypeScript 2.8, we can leverage conditional types and features from earlier versions of TypeScript to create this majestic piece of generic and type-safe beauty:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface ImmutableObject<T> {
    get<P extends keyof T>(key: P): T[P] extends Array<infer U> ? ImmutableList<U> : T[P] extends object ? ImmutableObject<T[P]> : T[P]; 
}

interface ImmutableList<T> {
    count(): number;
    get(index: number): T extends object ? ImmutableObject<T> : T;
}

// The immutable.js fromJS() API
declare function fromJS<T>(obj: T): ImmutableObject<T>;

So how does this work? To illustrate, lets update our example interfaces to include a 3rd interface


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
interface IGeographicCoordinate {
    lat: number;
    lng: number;
}

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

interface ISearchResult {
    query: string;
    results: IPlacemark[];
}

Then let's start with the get method of ImmutableObject<T>

The fragment P extends keyof T describes the type placeholder P on this method that is any variable that is a valid member name of type T. Using our above interfaces as an example, keyof IPlacemark is the type equivalent of:

 "id" | "coordinate" | "name"

The type of key parameter of the get method is constrained to any of the member names in T (auto-deduced via the keyof operator), thus you cannot plug in names of members that are not part of T.

The return type T[P] is a mapped type that gives the corresponding type based on the value you put in for the key parameter. If we use IPlacemark as an example:
  • Calling get with "id" will deduce T[P] to the type: number
  • Calling get with "coordinate" will deduce T[P] to the type: IGeographicCoordinate
  • Calling get with "name" will deduce T[P] to the type: string
We then leverage the new conditional types feature to conditionally deduce the appropriate return type based on properties of T[P] that we can ask of through the conditional types feature:
  • If the mapped type is an array (T[P] extends Array) resolve the return type to ImmutableList of the inferred type U. The infer U fragment defines an ad-hoc type placeholder U that will resolve to the item type of the array.
  • Otherwise, if it is an object (T[P] extends object) resolve the return type to ImmutableObject of the mapped type
  • Otherwise, it will resolve the return type to the mapped type.
To illustrate with ISearchResult as an example:
  • Calling get with "query" will deduce a return type of: string
  • Calling get with "results" will deduce a return type of: ImmutableList<IPlacemark>
Now on to the get method of ImmutableList<T>

ImmutableList<T> is a immutable collection wrapper. The get method here simply returns the item of type T at the specified index. Once again we leverage conditional types to ask some questions of the type T to deduce the correct return type.
  • If T is an object (T extends object), resolve the return type to: ImmutableObject<T>, making this mapped type fully recursive all the way down however many levels it needs to go
  • Otherwise T must only be a primitive type, so resolve the return type as-is.
Now how do we know this actually works and is not some abstract piece of theory?

See for yourself on the TypeScript playground (NOTE: If the code fragment doesn't load in the playground, you can copy/paste the code from this gist I've prepared earlier)

No red squiggles! Also, put your mouse over every variable, you will see the type matches the type specified in the respective end-of-line comment.

Hopefully this post has shed some light on how powerful this new conditional types feature of TypeScript 2.8 really is

No comments: