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
|