blog-static/content/blog/typescript_typesafe_events.md

5.5 KiB

title date tags
Type-Safe Event Emitter in TypeScript 2021-09-04T17:18:49-07:00
TypeScript

I've been playing around with TypeScript recently, and enjoying it too. Nearly all of my compile-time type safety desires have been accomodated by the language, and in a rather intuitive and clean way. Today, I'm going to share a little trick I've discovered which allows me to do something that I suspect would normally require dependent types.

The Problem

Suppose you want to write a class that emits events. Clients can then install handlers, functions that are notified whenever an event is emitted. Easy enough; in JavaScript, this would look something like the following:

{{< codelines "JavaScript" "typescript-emitter/js1.js" 1 17 >}}

We can even write some code to test that this works (just to ease my nerves):

{{< codelines "JavaScript" "typescript-emitter/js1.js" 19 23 >}}

As expected, we get:

Ended!
Started!

As you probably guessed, we're going to build on this problem a little bit. In certain situations, you don't just care that an event occured; you also care about additional event data. For instance, when a number changes, you may want to know the number's new value. In JavaScript, this is a trivial change:

{{< codelines "JavaScript" "typescript-emitter/js2.js" 1 17 "hl_lines = 6-8" >}}

That's literally it. Once again, let's ensure that this works by sending two new events: stringChange and numberChange.

{{< codelines "JavaScript" "typescript-emitter/js2.js" 19 23 >}}

The result of this code is once again unsurprising:

New number value is:  1
New string value is:  3

But now, how would one go about encoding this in TypeScript? In particular, what is the type of a handler? We could, of course, give each handler the type (value: any) => void. This, however, makes handlers unsafe. We could very easily write:

emitter.addHandler("numberChanged", (value: string) => {
    console.log("String length is", value.length);
});
emitted.emit("numberChanged", 1);

Which would print out:

String length is undefined

No, I don't like this. TypeScript is supposed to be all about adding type safety to our code, and this is not at all type safe. We could do all sorts of weird things! There is a way, however, to make this use case work.

The Solution

Let me show you what I came up with:

{{< codelines "TypeScript" "typescript-emitter/ts.ts" 1 19 "hl_lines=1 2 8 12">}}

The important changes are on lines 1, 2, 8, and 12 (highlighted in the above code block). Let's go through each one of them step-by-step.

  • Line 1: Parameterize the EventEmitter by some type T. We will use this type T to specify the exact kind of events that our EventEmitter will be able to emit and handle. Specifically, this type will be in the form { event: EventValueType }. For example, for a mouseClick event, we may write { mouseClick: { x: number, y: number }}.
  • Line 2: Add a proper type to handlers. This requires several ingredients of its own:
    • We use index signatures to limit the possible names to which handlers can be assigned. We limit these names to the keys of our type T; in the preceding example, keyof T would be "mouseClick".
    • We also limit the values: T[eventName] retrieves the type of the value associated with key eventName. In the mouse example, this type would be { x: number, y: number }. We require that a key can only be associated with an array of functions to void, each of which accepts T[K] as first argument. This is precisely what we want; for example, mouseClick would map to an array of functions that accept the mouse click location.
  • Line 8: We restrict the type of emit to only accept values that correspond to the keys of the type T. We can't simply write event: keyof T, because this would not give us enough information to retrieve the type of value: if event is just keyof T, then value can be any of the values of T. Instead, we use generics; this way, when the function is called with "mouseClick", the type of K is inferred to also be "mouseClick", which gives TypeScript enough information to narrow the type of value.
  • Line 12: We use the exact same trick here as we did on line 8.

Let's give this a spin with our numberChange/stringChange example from earlier:

{{< codelines "TypeScript" "typescript-emitter/ts.ts" 21 27 >}}

The function calls on lines 24 and 25 are correct, but the subsequent two (on lines 26 and 27) are not, as they attempt to emit the opposite type of the one they're supposed to. And indeed, TypeScript complains about only these two lines:

code/typescript-emitter/ts.ts:26:30 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
26 emitter.emit("numberChange", "1");
                                ~~~
code/typescript-emitter/ts.ts:27:30 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
27 emitter.emit("stringChange", 3);
                                ~
Found 2 errors.

And there you have it! This approach is now also in use in Hydrogen, a lightweight chat client for the Matrix protocol. In particular, check out EventEmitter.ts.