Strongly Typed Event Emitters with Conditional Types
Event emitters are a common pattern in the Node.js world, but for TypeScript developers, they can carry some woe. TypeScript allows us to type “stringly-typed” APIs like event emitters and the web’s EventTarget
interface (addEventListener
and friends), but without more advanced type operators we’re stuck manually specifying every event name for every API that handles them. This works fine when someone else does the work for you and the events don’t change often (as is the case for built-in events found in Node and the web platform) but if you have your own events, things get verbose very fast.
When writing custom event code, TypeScript developers often choose to not strictly type event emitters. In that case, the compiler is not aware of which events exist so it cannot provide tooling and error checking. It is easy to mistype an event name or forget to pass a payload and callbacks won’t infer their parameter types requiring annoying manual annotation.
There are a number of alternative EventEmitter
libraries which cater to TypeScript developers. For the most part these libraries provide a new EventEmitter
implementation that is easier to type. This approach works but adds layers of abstraction which adds complexity, especially when dealing with something like a web socket rather than Node’s built-in EventEmitter
.
What we really want is a type that can layer event names and types over any existing event emitter API.
A partial solution with keyof
Previous work by e.g. rsms and kimamula has shown that the keyof
and indexed access type operators added in TypeScript 2.1 enable a much improved way of typing event emitter APIs. Events are specified in an event record: an interface mapping event names to the type of that event’s payload. keyof
lets us extract all the event names and the indexed access operator lets us reference the corresponding event payload type.
The indexed access operator looks a lot like regular square-bracket property access in JavaScript and works pretty much the same way. The big difference is it indexes with a type assignable to string
(i.e. a string literal type or a union of string literal types) and results in a union of the types of the given properties.
Using these two type operators, we can implement a better type for event emitters than before:
Line 11 declares a new type parameter K
that is a union of all the keys of T
, and then declares a single signature for on
which can take any of those keys as the first argument and accepts a callback of type T[K]
which uses the index access operator to give us our payload type.
The great part here is TypeScript can now infer a type for our callback parameters and we got there without having to write separate signatures for each event. The problem is that it doesn’t support a very common pattern: events with no payload. In such cases, we want to get an error if we pass a listener callback with a parameter, and we don’t want to have to redundantly pass an undefined
payload when emitting (see line 21 above).
The void
type seems like a natural choice to use for declaring an event without a payload in our event record. Now what we need is a way to say that these void events have a different signature.
A general solution with conditional types
Conditional types, a new feature in TypeScript 2.8, allows us to express a type that chooses its type based on another type. Conditional types may take a little bit to understand, but I promise you the effort is worth it. I highly recommend copying these examples into an editor so you can learn by exploring!
So, let’s consider a type IsNumber
which is either the typestrue
or false
depending on its type parameter T
.
The choice operation is distributive over unions, which means any time you see TSomeUnion extends condition ? T : F
, you can read it as equivalent to the following pseudo-code:
For each member of TSomeUnion,
If member extends condition, then
Add T to the resulting union
Else
Add F to the resulting union
We can explore this a bit by passing a union to IsNumber
:
The never
type is equivalent to an empty union so adding never
to a union is effectively a no-op. Thus we can use never
in a conditional to say that we don’t want that choice to impact the resulting type. This allows us to filter out members of a union type by returning never
on that conditional branch.
We can now write a MatchingKeys
type which works like keyof
but can filter keys whose values are of a particular type:
Line 4 declares a type parameter K
that serves as a convenient temporary name for the keys of TRecord
and isn’t intended to be passed from users of MatchingKeys
. Both the extends clause and the default are necessary. Without the extends clause, the type of K
is not well-understood by the compiler — it just sees a string union but doesn’t know that those strings correspond to properties of TRecord
. And without the default, K
would always need to be passed. This pattern is very helpful with complex types as you’ll see in a bit.
Line 5 is where the magic begins. We open with our “iteration” pattern from above, but our conditional is itself another conditional type. The inner conditional will be K
if TRecord[K] extends TMatch
and never
otherwise. So we “iterate” through each member of K
, check if that property of TRecord
is what we’re looking for, and if so, add that K
to the result.
A good exercise to test your understanding is to ponder why you can’t get away with just the inner conditional type TRecord[K] extends TMatch ? K : never
. Spoiler: it’s because K
is a union of all of TRecord
’s keys, so the indexed access operator will give us a union of all the types of TRecord
’s properties, and we test if that union extends our match type. In other words, that conditional can be read “If each value of TRecord
extends TMatch
, return a union of all of TRecord
’s keys, otherwise return never”. We need the iteration pattern to be able to filter out specific members of K
.
Now for convenience, let’s make a type that partially applies MatchingKeys
with the void
type:
And now we have all we need to describe a strongly typed API for event emitters! It looks like this:
This type takes two user-provided type parameters: the record of events and, optionally, a separate record of emit events. Lines 4–7 declare a bunch of temporaries so we can refer to them conveniently. We use the built-in type Exclude
to invert VoidKeys
into all the keys that aren’t void
, and create two overloads for on
, emit
, and the rest of the methods: one for the void
keys which don’t have a payload, and one for the non-void
keys which have a defined payload.
Creating a Type Library
To generalize this pattern into a library, there are a few additional complexities we need to handle. First, we need a way to layer this API on top of any existing event-emitter type. We don’t want to add any method overrides for methods that don’t exist in the first place (event emitters in the wild have somewhat different APIs). Additionally, we need to remove the original definitions of any overridden methods otherwise we would still have the loose signature sitting around just waiting to cause a bug or ten.
We need two more TypeScript types to accomplish what we want: the mapped object type and the intersection type. Mapped object types allow us to express an object type’s keys by mapping over a union of strings. For example, the built-in type Pick used heavily below is defined as:
Play around with Pick
, passing various object types for T
and string literal unions for K
. For example, Pick<{x: number, y: number}, 'x'>
results in {x: number}
. Note that the constraint on K’s definition ensures that we are only able to pass keys of T. Passing 'z'
for K
would thus result in an error.
Intersection types, created with the &
type operator, are a composite type that has all the methods and properties of &
’s operands. This allows us to build up our StrictEventEmitter
piece-wise from its various components. Take a look:
And there you have it: a handy-dandy type-only library that strongly types event emitter events! Take a look at the implementation on GitHub — it’s just a few lines long 🙃 I’ll post something later about more of the interesting patterns in this library, such as using TypeRecord
to stash away type parameters for later use.
👊,
~@bterlson