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 leading to its 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, ensuring that it matches what a handler like onMessage is intended to receive. Let’s take a look.

A TypeScript 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 EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T) => void;

interface Emitter<T extends EventMap> {
  on<K extends EventKey<T>>
    (eventName: K, fn: EventReceiver<T[K]>): void;
  off<K extends EventKey<T>>
    (eventName: K, fn: EventReceiver<T[K]>): void;
  emit<K extends EventKey<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() {});

Towards production

Replacing our example emitter with a proven implementation (whether Node’s EventEmitter or any of several various browser-friendly derivatives) will minimize the number of edges that turn up at runtime. To take advantage, we just need to constrain the existing, probably-JavaScript module within our typed Emitter interface.

Using a factory function

Function return types are an easy to narrow a TypeScript interface. So long as an emitter implements the Emitter interface, we can always assign it to that interface:

import {EventEmitter} from 'events';

function createEmitter<T extends EventMap>(): Emitter<T> {
  return new EventEmitter();
}

Of course, the narrowed type will keep us from accessing non-Emitter methods in the underlying implementation, but that’s also kind of the whole point.

const e = createEmitter<{foo: 'bar'}>();

// => error: Property 'listenerCount' does not exist
e.listenerCount()

// => error: type '"x"' is not assignable to '"foo"'
e.on('x', () => {});

Using a generic base class

Factory functions are great for standalone emitters, but feel less natural where a class implementing some other logic also behaves like an event emitter. In this case, the emitter’s methods are likely extended by the class implementation:

class MyClass extends EventEmitter {
    // ...
}

In this circumstance we’re mostly tied to the existing, widely accepting behavior of the underlying EventEmitter. We could implement MyClass methods that constrain the type and invoke the inherited method underneath, but it would be nice to avoid adding this extra boilerplate to every single event-emitting class in the codebase.

One alternative is to create a generic base class that will proxy an event emitter for us.

export class MyEmitter<T extends EventMap> implements Emitter<T> {
  private emitter = new EventEmitter();
  on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>) {
    this.emitter.on(eventName, fn);
  }

  off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>) {
    this.emitter.off(eventName, fn);
  }

  emit<K extends EventKey<T>>(eventName: K, params: T[K]) {
    this.emitter.emit(eventName, params);
  }
}

This allows us to contain the boilerplate logic in a single place, while enabling fairly natural usage in other classes.

class MyClass extends MyEmitter<{foo: Bar}> {}

const c = new MyClass();

// => type '"x"' is not assignable to parameter of type '"foo"'
c.on('x', (n: number) => {
  console.log('got "x"', n);
});

Featured