Event Emitters in TypeScript
- 10/20/2019
- ·
- #typescript
- #howto
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:
- when does the method get called?
- 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 extend
ed 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);
});