Browse Source

Add draft of TypeScript typesafe event emitter post

master
Danila Fedorin 3 months ago
parent
commit
3c905aa1d7
  1. 23
      code/typescript-emitter/js1.js
  2. 23
      code/typescript-emitter/js2.js
  3. 27
      code/typescript-emitter/ts.ts
  4. 120
      content/blog/typescript_typesafe_events.md

23
code/typescript-emitter/js1.js

@ -0,0 +1,23 @@
class EventEmitter {
constructor() {
this.handlers = {}
}
emit(event) {
this.handlers[event]?.forEach(h => h());
}
addHandler(event, handler) {
if(!this.handlers[event]) {
this.handlers[event] = [handler];
} else {
this.handlers[event].push(handler);
}
}
}
const emitter = new EventEmitter();
emitter.addHandler("start", () => console.log("Started!"));
emitter.addHandler("end", () => console.log("Ended!"));
emitter.emit("end");
emitter.emit("start");

23
code/typescript-emitter/js2.js

@ -0,0 +1,23 @@
class EventEmitter {
constructor() {
this.handlers = {}
}
emit(event, value) {
this.handlers[event]?.forEach(h => h(value));
}
addHandler(event, handler) {
if(!this.handlers[event]) {
this.handlers[event] = [handler];
} else {
this.handlers[event].push(handler);
}
}
}
const emitter = new EventEmitter();
emitter.addHandler("numberChange", n => console.log("New number value is: ", n));
emitter.addHandler("stringChange", s => console.log("New string value is: ", s));
emitter.emit("numberChange", 1);
emitter.emit("stringChange", "3");

27
code/typescript-emitter/ts.ts

@ -0,0 +1,27 @@
class EventEmitter<T> {
private handlers: { [eventName in keyof T]?: ((value: T[eventName]) => void)[] }
constructor() {
this.handlers = {}
}
emit<K extends keyof T>(event: K, value: T[K]): void {
this.handlers[event]?.forEach(h => h(value));
}
addHandler<K extends keyof T>(event: K, handler: (value: T[K]) => void): void {
if(!this.handlers[event]) {
this.handlers[event] = [handler];
} else {
this.handlers[event].push(handler);
}
}
}
const emitter = new EventEmitter<{ numberChange: number, stringChange: string }>();
emitter.addHandler("numberChange", n => console.log("New number value is: ", n));
emitter.addHandler("stringChange", s => console.log("New string value is: ", s));
emitter.emit("numberChange", 1);
emitter.emit("stringChange", "3");
emitter.emit("numberChange", "1");
emitter.emit("stringChange", 3);

120
content/blog/typescript_typesafe_events.md

@ -0,0 +1,120 @@
---
title: "Type-Safe Event Emitter in TypeScript"
date: 2021-09-04T17:18:49-07:00
draft: true
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:
```
Started!
Ended!
```
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:
`stringChanged` and `numberChanged`.
{{< 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 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 `numberChanged`/`stringChanged` 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!
Loading…
Cancel
Save