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 k1
s have a mix of types? Then the resulting type of the values should be their union:
and, just as importantly, TypeScript will complain if we try to pluck a non-existent key:
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:
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: StopTime
s (which notes bus/train arrivals at a stop and have a stopId
field) and Transfer
s (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 ofkeyof T
.obj[idField]
has a type ofT[keyof T]
.idMapping
has an index type ofstring
(the[oldId: string]
bit).T[keyof T]
is not a subtype ofstring
, but we're indexing intoidMapping
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.
If you enjoyed this post, you might also enjoy my book, Effective TypeScript: 62 Specific Ways to Improve Your TypeScript (O’Reilly 2019). Material from this post made its way into Chapter 4 (Type Design) and Chapter 6 (Types Declarations and @types
).