Teaching TypeScript to Help You with Events (Generically)
Posted on Jan 19 2022
Table of Contents
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!
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.
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-capable editor, you can write the follwing TypeScript:
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. 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:
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:
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. It’s just easier to show you what this means:
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:
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:
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:
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. But that’s just fine with me! I really only want this functionality in a… function.
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:
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
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:
$ deno run full-code-example.ts
Check file:///home/daniel/code/typing-is-hard/full-code-example.ts
You pressed the A key!
And the language server helped us every step of the way. Beautiful.