From 3c905aa1d7b311890bb467552d6c0474f809eb0e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Sat, 4 Sep 2021 18:32:08 -0700 Subject: [PATCH] Add draft of TypeScript typesafe event emitter post --- code/typescript-emitter/js1.js | 23 ++++ code/typescript-emitter/js2.js | 23 ++++ code/typescript-emitter/ts.ts | 27 +++++ content/blog/typescript_typesafe_events.md | 120 +++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 code/typescript-emitter/js1.js create mode 100644 code/typescript-emitter/js2.js create mode 100644 code/typescript-emitter/ts.ts create mode 100644 content/blog/typescript_typesafe_events.md diff --git a/code/typescript-emitter/js1.js b/code/typescript-emitter/js1.js new file mode 100644 index 0000000..03f64f1 --- /dev/null +++ b/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"); diff --git a/code/typescript-emitter/js2.js b/code/typescript-emitter/js2.js new file mode 100644 index 0000000..313c270 --- /dev/null +++ b/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"); diff --git a/code/typescript-emitter/ts.ts b/code/typescript-emitter/ts.ts new file mode 100644 index 0000000..c63cc0b --- /dev/null +++ b/code/typescript-emitter/ts.ts @@ -0,0 +1,27 @@ +class EventEmitter { + private handlers: { [eventName in keyof T]?: ((value: T[eventName]) => void)[] } + + constructor() { + this.handlers = {} + } + + emit(event: K, value: T[K]): void { + this.handlers[event]?.forEach(h => h(value)); + } + + addHandler(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); diff --git a/content/blog/typescript_typesafe_events.md b/content/blog/typescript_typesafe_events.md new file mode 100644 index 0000000..593c1d7 --- /dev/null +++ b/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!