site.lyte.dev/content/blog/teaching-typescript-to-help-you-with-events.md
2022-01-19 10:05:12 -06:00

7 KiB

image date imageOverlayColor imageOverlayOpacity heroBackgroundColor description title draft
/img/dried-lava.jpg 2022-01-18T16:36:00-06:00 #000 0.5 #333 Teaching TypeScript to Help You with Events (Generically) 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!

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.