121 lines
5.5 KiB
Markdown
121 lines
5.5 KiB
Markdown
---
|
|
title: "Type-Safe Event Emitter in TypeScript"
|
|
date: 2021-09-04T17:18:49-07:00
|
|
tags: ["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](https://en.wikipedia.org/wiki/Dependent_type).
|
|
|
|
### 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:
|
|
|
|
```TypeScript
|
|
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](https://www.typescriptlang.org/docs/handbook/2/objects.html#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](https://github.com/vector-im/hydrogen-web),
|
|
a lightweight chat client for the [Matrix](https://matrix.org/) protocol. In particular, check out [`EventEmitter.ts`](https://github.com/vector-im/hydrogen-web/blob/master/src/utils/EventEmitter.ts).
|