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.

Current Node Type Declarations for addListener

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