Build a type-safe event emitter in Node.js using TypeScript
How to build a type-safe event emitter in Node.js using TypeScript
Nearly all programming languages have some event mechanism. The classic use-case for using an event paradigm is to decouple the event source from the event listener.
In Node.js, the built-in way to build an event source and subscriber is using the EventEmitter class. You can read a more detailed explanation here, but for a quick recap, it works as follows:
import { EventEmitter } from 'events'
const eventBroker = new EventEmitter()
eventBroker.on('event-1', () => {
console.log('event-1')
});
eventBroker.on('event-2', (arg1, arg2) => {
console.log('event-2', arg1, arg2)
});
eventBroker.emit('event-2', 4, 'test')
eventBroker.emit('event-1')
See it in action π
In this example, two listeners are registered to receive events, and two events are published. Each listener will receive their listened-for event a single time.
This is pretty straightforward. However, as with most JS code, it would be easy to introduce a bug without knowing. For example, if event-2 were refactored to drop the second argument, it would be easy to forget to refactor the listener for this event because other than the name event-2, thereβs no tangible link between them.
So how can we address this using TypeScript? Well, it turns out that it's pretty easy.
We define a class to wrap the typeless EventEmitter:
class TypedEventEmitter<TEvents extends Record<string, any>> {
private emitter = new EventEmitter()
emit<TEventName extends keyof TEvents & string>(
eventName: TEventName,
...eventArg: TEvents[TEventName]
) {
this.emitter.emit(eventName, ...(eventArg as []))
}
on<TEventName extends keyof TEvents & string>(
eventName: TEventName,
handler: (...eventArg: TEvents[TEventName]) => void
) {
this.emitter.on(eventName, handler as any)
}
off<TEventName extends keyof TEvents & string>(
eventName: TEventName,
handler: (...eventArg: TEvents[TEventName]) => void
) {
this.emitter.off(eventName, handler as any)
}
}
You may have noticed the use of as any
, which I assume caused you to raise an eyebrow. Let me explain. In strict mode, an error is raised because TEvents[TEventName] != any[]
. This is because (...args: TEvents[TEventName]) => void
is narrower than the default typings on the EventEmitter
type of (...args: any[]) => void
and since function parameters are contravariant, this results in a type error. Because we constrain types when events are raised via the emit
function, we can safely suppress this error with an as any
type (@tristan).
This wrapper allows us to define our events in a type-safe manner using a type:
type LocalEventTypes = {
'event-1': []
'event-2': [arg1: number, arg2: string]
}
If I were to write a type-safe version of the javascript example above, it would look like:
const eventBroker = new TypedEventEmitter<LocalEventTypes>()
eventBroker.on('event-1', () => {
console.log('event-1')
})
eventBroker.on('event-2', (arg1: number, arg2: string) => {
console.log('event-2', arg1, arg2)
})
eventBroker.emit('event-2', 4, 'test')
eventBroker.emit('event-1')
See it in action π
More importantly, if the last event-2 argument is removed, there are type errors π
Before I wrap this post up, I must thank my colleague @tristan for sharing this with me this morning. Moreover, apologies if I stole a blog idea from you π€