Build a type-safe event emitter in Node.js using TypeScript

How to build a type-safe event emitter in Node.js using TypeScript

Build a type-safe event emitter in Node.js using TypeScript
Photo by Ian Schneider / Unsplash

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 πŸ€—