Add new post
This commit is contained in:
parent
35d0ae7f9f
commit
8fe695fcbd
2 changed files with 233 additions and 1 deletions
232
content/blog/teaching-typescript-to-help-you-with-events.md
Normal file
232
content/blog/teaching-typescript-to-help-you-with-events.md
Normal file
|
@ -0,0 +1,232 @@
|
|||
---
|
||||
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
|
||||
Check file:///home/daniel/code/typing-is-hard/example.ts
|
||||
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
|
|
@ -59,5 +59,5 @@ main > .highlight pre.chroma
|
|||
for c in kn nt o gd ow
|
||||
.{c} { color: var(--syntax-mag) }
|
||||
|
||||
for c in c ch cm c1 cs cp cpf gu
|
||||
for c in c ch cm c1 cs cp cpf gu gp go
|
||||
.{c} { color: var(--syntax-sh) }
|
||||
|
|
Loading…
Reference in a new issue