JavaScript Event Evolution

EventDispatcher is so old school. It's time for something more modern.

·

4 min read

Node's events module is ancient, as ancient as Node itself. It heralds from a time where TypeScript wasn't even conceived yet. And it tells. There is no easy way to tell TypeScript which events exist and what arguments they receive. Furthermore, there is no good way to return results from event propagation. While it's great for vanilla JavaScript, it's cumbersome for TypeScript.

Enter Typed Events: An event system from scratch designed for TypeScript, written by yours truly, so I may be biased.

Various libraries have pivoted to an "unsub" callback returned from the "sub" method. Rather than calling .off('someEvent', myEventListener), you simply call unsub(). Typed Events adopts this style. Following is an example:

import { Event } from '@kiruse/typed-events';

export default class MyClass {
  readonly onConnect = Event<{ remote: string }>();
  readonly onClose   = Event<{ code: number }>();
  readonly onError   = Event<any>();
  readonly onMessage = Event<{ type: string; value: unknown }>();

  connect(remote: string) {
    const ws = this.#getWebSocket();
    ws.on('message', (msg) => {
      const payload = JSON.parse(msg.toString());
      this.onMessage({ type: 'some-message', value: payload });
    });
    ws.on('error', (err) => {
      this.onError.emit(err);
    });
    ws.on('close', (code) => {
      this.onClose.emit({ code });
    });
    this.onConnect.emit({ remote });
  }
}

const inst = new MyClass().connect('localhost:443');
inst.onClose(({ args: { code }}) => {
  if (canReconnect(code)) {
    inst.connect('localhost:443');
  }
});

const unsubMsg = inst.onMessage(({ args: msg }) => {
  switch (msg.type) {
    case 'unsub':
      unsubMsg();
      break;
    default:
      console.log(msg);
  }
});

Compare this to vanilla EventEmitters. With TypeScript, the easiest way to mixin the EventEmitter is by defining a helper method:

import { EventEmitter } from 'events';

export type EventMap = {
  [event: string]: (...args: any[]) => void;
}
type EventSignatures<M extends EventMap> = {
  [event in keyof M]: {
    on(event: event, listener: M[event]): void;
    once(event: event, listener: M[event]) => void): void;
    off(event: event, listener: M[event]) => void): void;
  }
}[keyof M];

type Ctor<Args extends any[] = any[], T = any> = {
  new (...args: Args) => T;
};

type CtorArgs<T> = T extends Ctor<infer Args> ? Args : never;
type CtorInst<T> = T extends Ctor<any, infer I> ? I : never;

function mixinEvents<
  T extends Ctor,
  Events extends EventMap
>(claz: T):
Ctor<
  CtorArgs<T>,
  CtorInst<T> & EventEmitter & EventSignatures<Events>
>
{
  return class extends T {
    constructor(...args: CtorArgs<T>) {
      super(...args);
      EventEmitter.call(this);
    }
  } as any;
}

class Parent {}
class Child extends mixinEvents(Parent) {}

All of this just to get .on('event', (arg1, arg2) => { /* ... */ }) to be typed. Some of this is fairly advanced TypeScript usage too. You can't exactly expect beginners to write code like this just to incorporate events in their own classes. I don't guarantee the above code even works, I just wrote it down from memory.

Here are some advantages of Typed Events:

  • Simple & clear typing. You type the event in the same line you declare it.

  • Composition. No more mixins! Granted, you can compose an EventEmitter as well, but you'll either need to proxy all its methods, or force users to use an intermittent emitter, like myObject.events.on(...).

  • Results. There's a reason why you need to access the event arguments with onMessage(({ args: msg }) => {...}) and that's because the event object also exposes an optional result property. It borrows this concept from DOM events.

  • Safe & easy unsub. With .off, you cannot remove an anonymous or arrow function listener unless you store it somewhere. But the unsub callback returned by myObject.onConnect(...) does it for you! It also unambiguously removes the correct instance of a method if it was registered twice.

  • Once with predicates. I always wondered why this wasn't possible. Many times, I wanted to run an event handler exactly once, but only if some condition is met. You can call myObject.onMessage.oncePred(...) to do just that. This is handy for example when awaiting a specific confirmation from the server, but there can be more messages in between. If the predicate is not met, it won't run your handler, and of course it will also not remove your handler until it has been run.

To be fair, there are of course also disadvantages to Typed Events:

  • Memory footprint. Every single event is its own function, with its own memory of listeners and additional properties and methods.

  • Less generic. Its strength can also be a deal breaker. socket.io leverages the fact events are really just strings to propagate arbitrary events across the network and emit them on the other side. This isn't possible at all with Typed Events.

I'm all about Developer Experience (DX). That includes library API & how other developers use your code. It should be self-explanatory, lean and elegant. The less I need to open the documentation in my browser to look something up, the better. I believe Typed Events achieves this. EventEmitter may have been great 10 years ago (literally), but it didn't age well. My personal winners are Typed Events - hence why I wrote them.