Event Emitters in TypeScript

From UI interactions to the event loop at the heart of Node.js, event-based programming is part and parcel of JavaScript development. JavaScript leans on events to enable parallelism, decouple logic, describe I/O, and glue all sorts of abstractions together. But as useful as events can be, in JavaScript’s dynamic type system they’re also a fantastic way to lose track of data.

Consider a simple example:

function onMessage (data) {
  // handle the event
}

window.addEventListener('message', onMessage);

As long as onMessage is defined right next to its binding, we’re a quick stop by MDN away from knowing when onMessage will be triggered, plus a bit of in-house documentation away from knowing what data to expect.

If we consider onMessage in isolation, however, we might find ourselves wondering:

  1. when does the method get called?
  2. what arguments will it receive?

The first question, while fair, runs counter to the point of an event-based application. We shouldn’t care. onMessage should do what onMessage does, regardless of the events that led up to invocation.

The second question, however, is extremely relevant. If we don’t understand the shape of the data argument, we won’t be able to handle it correctly. In JavaScript, we might get some clues by the function or variable name; if we’re lucky, the implementer may have left a friendly comment behind. But TypeScript lets us do one better. A type annotation will explain the function’s usage while ensuring that it’s used correctly. For instance:

function onMessage (data: PostMessageData): void {
  // ...
}

With an explicit data-type, data‘s name is a clerical detail. It could be d, or $, and it would still be clear what shape it would take.

But we’ve still only dealt with half of the equation. The real magic happens when we type the emitted event, too, to make sure that it matches what a handler like onMessage is intended to receive. Let’s take a look.

An event emitter interface

TypeScript can help clarify an event-driven program by explicitly declaring what events an event emitter will handle. There are plenty of examples of TypeScript event handlers in the wild, but it’s easy enough to implement your own. The key is just to constrain an Emitter within a map of “known” events that the emitter can produce.

In general terms, the interface of a TypeScript event emitter looks something like this:

type EventMap = Record<string, any>;

type EventReceiver<T> = (params: T) => void;

interface Emitter<T extends EventMap> {
  on<K extends string & keyof T>
    (eventName: K, fn: EventReceiver<T[K]>): void;
  off<K extends string & keyof T>
    (eventName: K, fn: EventReceiver<T[K]>): void;
  emit<K extends string & keyof T>
    (eventName: K, params: T[K]): void;
}

Next, let’s write a toy eventEmitter that implements the Emitter interface.

// `listeners` are unbounded -- don't use this in practice!
function eventEmitter<T extends EventMap>(): Emitter<T> {
  const listeners: {
    [K in keyof EventMap]?: Array<(p: EventMap[K]) => void>;
  } = {};

  return {
    on(key, fn) {
      listeners[key] = (listeners[key] || []).concat(fn);
    },
    off(key, fn) {
      listeners[key] = (listeners[key] || []).filter(f => f !== fn);
    },
    emit(key, data) {
      (listeners[key] || []).forEach(function(fn) {
        fn(data);
      });
    },
  };
}

When we use eventEmitter to construct a new emitter, we can now provide a list of events that the emitter can handle. If we do, any handlers for unknown events or attempts to reference unknown event data will now be caught by the TypeScript compiler.

const emitter = eventEmitter<{
  data: Buffer | string;
  end: undefined;
}>();

That’s all it takes! A few quick annotations and we’ll never have to worry about losing event data again. Try attaching a mix of valid and mismatched handlers, and watch tsc spring into action:

// OK!
emitter.on('data', (x: string) => {});

// Error! 'string' is not assignable to 'number'
emitter.on('data', (x: number) => {});

// Error! '"foo"' is not assignable to '"data" | "end"'.
emitter.on('foo', function() {});

Hey, it's RJ—thanks for reading! If you enjoyed this post, would you be willing to share it on Twitter, Facebook, or LinkedIn?