site.lyte.dev/content/blog/teaching-typescript-to-help-you-with-events.md

233 lines
7 KiB
Markdown
Raw Normal View History

2022-01-18 16:37:01 -06:00
---
image: "/img/dried-lava.jpg"
date: "2022-01-18T16:36:00-06:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#333"
description: ""
title: "Teaching TypeScript to Help You with Events (Generically)"
draft: false
---
I wanted my custom event system to have all the nice typed goodness and it took
me a lot of brain-twisting and a couple of meetings to finally rubber duck it
out. Now, I will share my hard-won knowledge with you!
<!--more-->
# TL;DR
The following code won't run, but you can see the main constructs at work. Also, [here's the full code at the end of this post](#full-code).
```ts
type MyInputEventMap = {
key: MyKeyEvent;
mouse: MyMouseEvent;
};
export class EventBus<T extends Record<string, any>> {
#handlers: Map<keyof T, Handler[]>;
dispatch<S extends string>({ eventType, ...eventData }: { eventType: S } & T[S]) => void): void {
(this.#handlers.get(event) || []).forEach((h: Handler) => h(data));
}
subscribe<S extends string>(event: S, callback: (ev: T[S]) => void): void {
this.#handlers.get(event).push(callback);
}
}
```
# But... why?
In your [LSP][lsp]-capable editor, you can write the follwing TypeScript:
```ts
window.addEventListener("keydown",
```
And it will auto-hint to you the the callback you need to provide must take
a `KeyboardEvent`. TypeScript seems to do this using [function
overloading][fo]. This is great, but I needed a more generical solution for an
event bus class I was building. I wanted to get the same experience when
writing something like the following:
```ts
const eventBusInstance = new EventBus<MyCustomEvents>();
eventBusInstance("eventType",
// my text editor should tell me exactly what type the callback expects here
```
You can see we have a generic EventBus class, so it's a little more hairy, but
we still know the "event type" string at compile time -- at least in this case
-- so we _should_ still be able to replicate it.
# Teaching the Compiler
The first thing we need to understand is that we will have to inform the
compiler of this first argument at compile-time. If we can't do that, we will
have no way of informing the compiler what the event type will be. Luckily, we
have a means of doing this:
```ts
function genericFunction<T>(argument: T) {
console.log(argument);
}
```
Now, If we call `genericFunction`, no matter what we pass in, the compiler will
infer the type from whatever is passed in and `T` will now be whatever type
`argument` is.
Usually, we use generics to make something work across some subset of types. We
can certainly still do that, but they can also be used to inform the compiler
about things.
The other neat thing about TypeScript's type system is that we can [index a type
at compile time][iat]. It's just easier to show you what this means:
```ts
type Paper {
size: Vector2<float>;
color: Color;
contents: Image;
}
type PaperContents = Paper["contents"] // type PaperContents = Image
```
I know, this seems useless, but when we're dealing with generics, you can see
how this might be useful! One more interesting and possibly useful tidbit in
this vein is `keyof`. Here, let me show you:
```ts
type ContrivedCSSAttributeValueTypes = {
display: "flex" | "block" | "inline" /* ... */;
widthInPixels: number;
};
type ContrivedCSSAttributeName = keyof ContrivedCSSAttributeValueTypes;
interface ContrivedCSSAttribute {
name: ContrivedCSSAttributeName;
value: ContrivedCSSAttributeValueTypes[ContrivedCSSAttributeName];
// type ContrivedCSSAttribute.value = "flex" | "block" | "inline" | number
}
```
We've taken a very data-driven approach to defining our types here. Instead of
needing to specify every possible `value`, we can instead define each possible
value for each `name` and use indexing combined with `keyof` to give us all the
possible values. Really interesting!
But, as hinted, this is a really contrived example _and_ you can _still_
provide a bad type with `{ name: "display", value: 8 }` and the compiler would
think it's valid since the value here knows nothing of the name. Let's change
just a few things here:
```ts
type ContrivedCSSAttributeValueTypes = {
display: "flex" | "block" | "inline" /* ... */;
widthInPixels: number;
};
type ContrivedCSSAttributeName = keyof ContrivedCSSAttributeValueTypes;
interface ContrivedCSSAttribute<T extends ContrivedCSSAttributeName> {
name: T;
value: ContrivedCSSAttributeValueTypes[T];
}
```
Ok. Now it works, but we have to specify the `name` twice: once in the object
itself and once as the generic. Eww. See the following:
```ts
const attr: ContrivedCSSAttribute<"widthInPixels"> = {
name: "widthInPixels",
value: 99,
};
```
_But_, weirdly enough, a _function_ can infer the types. Not sure why an object
literal cannot. [Here seems to be the related GitHub issues][ghiss]. But that's
just fine with me! I really only _want_ this functionality in a... function.
```ts
function makeCssAttr<S extends ContrivedCSSAttributeName>(
name: S,
value: ContrivedCSSAttributeValueTypes[S]
): ContrivedCSSAttribute<S> {
return { name, value };
}
```
Now I get the expected super-cool compiler help from my language server when
I type:
```ts
makeCssAttr("display",
// why yes, you sexy programmer, you, I _am_ expecting "flex", "block", or
// "inline" here. you taught me so well.
```
So let's see if we can adapt what we learned to do what we want for our event
system. **Spoiler Alert**: we totally can.
# Full Code
```ts
type Handler = (event: any) => void;
export class EventBus<T extends Record<string, any>> {
#handlers: Map<keyof T, Handler[]>;
constructor() {
this.#handlers = new Map();
}
dispatch<S extends keyof T>({ event, ...data }: { event: S } & T[S]): void {
(this.#handlers.get(event) || []).forEach((h: Handler) => h(data));
}
subscribe<S extends keyof T>(event: S, callback: (ev: T[S]) => void): void {
const handlers = this.#handlers.get(event) || [];
handlers.push(callback);
this.#handlers.set(event, handlers);
}
unsubscribe<S extends keyof T>(
event: S,
callback: (event: T[S]) => void
): void {
const handlers = this.#handlers.get(event) || [];
const index = handlers.indexOf(callback);
if (index > -1) handlers.splice(index, 1);
}
}
export type KeyDownEventData = { key: string };
export type MouseDownEventData = { mouseButton: number };
type InputEventMap = {
keydown: KeyDownEventData;
mousedown: MouseDownEventData;
};
const inputEventBus = new EventBus<InputEventMap>();
inputEventBus.subscribe("keydown", ({ key }) =>
console.log(`You pressed the ${key} key!`)
);
inputEventBus.dispatch({ event: "keydown", key: "A" });
```
And let's just make sure this works:
```console
$ deno run full-code-example.ts
2022-01-19 10:05:12 -06:00
Check file:///home/daniel/code/typing-is-hard/full-code-example.ts
2022-01-18 16:37:01 -06:00
You pressed the A key!
```
And the language server helped us every step of the way. Beautiful.
[lsp]: https://microsoft.github.io/language-server-protocol/
[fo]: https://www.tutorialsteacher.com/typescript/function-overloading
[iat]: https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
[ghiss]: https://github.com/microsoft/TypeScript/issues/17574