A Typed pluck: exploring TypeScript 2.1’s mapped types

One of underscore.js’s most useful methods is _.pluck. It takes an array of objects and "plucks" one property out of each, returning an array of the resulting values:

> _.pluck([{k1: 1, k2: 3}, {k1: 5, k2: 1}], 'k1')
[1, 5]

This mixing of string literals and property names is a common pattern in JavaScript, but it’s always been a hard one for static analysis systems like Google’s Closure Compiler, Facebook’s Flow or Microsoft’s TypeScript to capture.

For example, here’s how TypeScript sees the return value of that _.pluck expression using the DefinitelyType definitions:

(I’m using screenshots of Visual Studio Code to show type information in this post. I highly recommend this approach for building intuition around how TypeScript interprets your program.)

The resulting type is any[]. This is the TypeScript equivalent of giving up. The type definitions for_.pluck say that it returns an array, but they have no idea of what. This isn't the type definitions' fault. Before TS 2.1, _.pluck wasn't possible to type any more precisely than this.

Mapped Types

Enter mapped types, one of the most interesting new features in TypeScript 2.1. Here’s what that same snippet looks like with a type definition that uses them:

The k1 properties are all numbers, so the resulting value has a type of number[]. Great! But what if the k1s have a mix of types? Then the resulting type of the values should be their union:

pluck returns an array of (string | number)

and, just as importantly, TypeScript will complain if we try to pluck a non-existent key:

Error caught! ‘k3’ isn’t a property of all the objects in the first parameter.

Here’s the definition of pluck using mapped types:

function pluck<T, K extends keyof T>(objs: T[], key: K): T[K][] {
return objs.map(obj => obj[key]);
}

There are quite a few things going on here:

  • generics (<T>)
  • keyof
  • subtypes (extends)
  • string literal types
  • mapped types (T[K])

Hovering over our call to pluck in vscode shows how TypeScript sees a call to this function:

The generic type parameters (T and K) are filled like so:

  • T is { k1: number; k2: number; }.
  • K is "k1".

With a little finagling, we can also get vscode to show us what keyof T is:

  • keyof T is "k1" | "k2".

Both "k1" and "k2" are string literal types. The only value with a type of "k1" is, well, "k1" (and null and undefined, if you want to be picky).

The pipe indicates a union type. To be part of a union type, a value can belong to the types of any of its constituents. So both "k1" and "k2" are members of the type "k1" | "k2".

When we pass in the literal "k1" to pluck, TypeScript infers its type (K) as "k1". This is a subtype of"k1" | "k2", so it's true that K extends keyof T. (In previous versions of TypeScript the type of 'k1' would have been inferred as string, rather than "k1".)

Finally, the return type is T[K][]. This is whatever value types correspond to the properties which are part of K. In our case, it's T["k1"][], which is to say number[].

This looks just like accessing a key in an object, but it’s a bit more flexible than that. K doesn't have to be a single string literal. We can imagine a more complex situation:

const k = Math.random() < 0.5 ? 'k1' : 'k2';
const vs = pluck([{k1: 1, k2: 'A'}, {k1: 5, k2: 'B'}], k)

In this case, K is "k1" | "k2" and T[K] is string|number. So the return type of pluck is(string|number)[]:

Incidentally, we don’t have to write out the return type explicitly in the definition of pluck. TypeScript can infer it just fine:

TypeScript correctly infers the return type of pluck() if we omit it.

Another example: updateIDs

You might object that the type machinery for pluck takes more space and is more complex than its implementation. (See: You Might Not Need TypeScript (or Static Types)) It's not even clear that pluck has much value in ES6 since instead of_.pluck(objs, 'key') you can write objs.map(obj => obj.key), which older versions of TypeScript are able to type properly.

Here’s a slightly more elaborate example from my own code. I’ve been working with GTFS feeds, which describe city transit systems. They contain lots of different IDs which appear in different files. When you merge two GTFS feeds, there might be ID collisions. Two bus stops might both be called “A”, for example. To disentangle this, we might rename one “A1” and the other “A2”. But then we’ll need to update stop IDs in a few other structures: StopTimes (which notes bus/train arrivals at a stop and have a stopId field) and Transfers (which records valid transfers between stops and have fromStopId and toStopId fields).

To facilitate this, I wrote an updateIds function:

Here are some examples of how it works:

> updateIds({k1: 'A', v: 2}, ['k1'], {'A': 'A1'})
{k1: 'A1', v: 2}
> updateIds({from: 'A', to: 'B', time: 180}, ['from', 'to'], {'A': 'A1', 'B': 'B1'})
{from: 'A1', to: 'B1', time: 180}

It would be nice if TypeScript could verify that all the keys in the idFields array were actually properties of the object. This would catch typos (is it stopId or stopID?) and other mixups.

With keyof and string literal subtypes, the declaration is easy. We just have to require that idFields be an array of keyof T:

function updateIds<T>(
obj: T,
idFields: (keyof T)[],
idMapping: {[oldId: string]: string}): T {
}

The implementation doesn’t quite work without modification, unfortunately:

The problem here is that:

  • idField has a type of keyof T.
  • obj[idField] has a type of T[keyof T].
  • idMapping has an index type of string (the [oldId: string] bit).
  • T[keyof T] is not a subtype of string, but we're indexing into idMapping with it.

The issue is that we’d really like a way to say that the id fields in obj all have string values. Unfortunately, I haven't been able to find a way to do this. So the only way to get the implementation to type check is to add any casts, which effectively disable type checking:

This isn’t so bad: calls to updateIds are still properly checked:

It would be nice if we could do something like this:

but TypeScript only allows index types [k: K] to be string or number, not subtypes of string. Another way would be to add some sort of assertion to the type signature requiring that T[K] = string.

Conclusions

With the addition of keyof, mapped types and string literal types, TypeScript has taken a big step towards capturing some of the more dynamic patterns found in JavaScript. Rather than banning these patterns, Microsoft has found a clever combination of features which allow them to be safely captured.