· hands on

Make Node.js EventEmitter Type-Safe

Node.js EventEmitter can be made type-safe in TypeScript using declaration merging. This ensures correct event names and listener signatures, enhancing IDE and type checker reliability. Additional libraries like "typed-emitter" can further streamline the process.

The EventEmitter in Node.js is one of the most frequently used APIs. It powers streams, servers, sockets, child processes, and most third-party libraries that need to listen to events. Its design is intentionally flexible, but that flexibility has consequences when you use it in TypeScript.

Contents

The Problem with EventEmitter

If you inspect the type definitions from @types/node, you’ll see that EventEmitter is essentially typed as follows:

on(event: string | symbol, listener: (...args: any[]) => void): this;

This is overly permissive. Any string is accepted as an event name, even if you misspell it. Listener arguments are typed as any[], so the compiler has no idea what shape your events carry. And because emit shares the same weak typing, you can pass the wrong number or type of arguments and nothing will stop you until runtime.

Declaration Merging to the Rescue

There is a straightforward solution. TypeScript has a feature called declaration merging that allows the typings of an interface and a class with the same name to be combined at design time.

This allows you to describe your events in the interface, while the class only has to extend Node’s EventEmitter without any boilerplate. Here is what it looks like in practice:

import { EventEmitter } from 'node:events';
 
export interface ChatRoom {
  on(event: 'join', listener: (name: string) => void): this;
  on(event: 'leave', listener: (name: string) => void): this;
}
 
export class ChatRoom extends EventEmitter {
  private users: Set<string> = new Set();
 
  public join(name: string) {
    this.users.add(name);
    this.emit('join', name);
  }
 
  public leave(name: string) {
    if (this.users.delete(name)) {
      this.emit('leave', name);
    }
  }
}
 
const room = new ChatRoom();
 
room.on('join', (user) => {
  console.log(`"${user}" joined the channel.`);
});
 
room.on('leave', (user) => {
  console.log(`"${user}" left the channel.`);
});

At runtime, ChatRoom is just a subclass of EventEmitter. But at design time, the interface is merged with the class. That means the compiler now knows that only "join" and "leave" are valid event names, and that listeners for those events will receive a name parameter.

By applying declaration merging, you get the best of both worlds. The runtime behavior is still provided by Node.js, so you don’t need to maintain your own event system. But the compiler now enforces correct event names and listener signatures, which means your IDE and TypeScript’s type checker become reliable allies when working with events.

Taking It Further

One limitation of the interface-merging trick is that you need to repeat the same event definitions across all of EventEmitter’s public APIs if you want full type safety. Defining on and emit is usually enough, but if your codebase also uses once, off, removeListener, or other variants, you’ll have to type each one explicitly. That can become verbose if you rely heavily on the full EventEmitter API.

To avoid this boilerplate, you can use a small utility library called typed-emitter. It provides a strongly typed version of EventEmitter out of the box, so you only need to declare your event map once and it automatically covers all the public methods (on, once, off, emit, and so on).

Example:

import { EventEmitter } from 'node:events';
import TypedEventEmitter, { type EventMap } from 'typed-emitter';
type TypedEmitter<T extends EventMap> = TypedEventEmitter.default<T>;
 
interface MyEvents {
  [event: string]: (...args: any[]) => void;
  join: (name: string) => void;
  leave: (name: string) => void;
}
 
export class ChatRoom extends (EventEmitter as new () => TypedEmitter<MyEvents>) {
  private users: Set<string> = new Set();
 
  public join(name: string) {
    this.users.add(name);
    this.emit('join', name);
  }
 
  public leave(name: string) {
    if (this.users.delete(name)) {
      this.emit('leave', name);
    }
  }
}
 
const room = new ChatRoom();
 
room.once('join', (user) => {
  console.log(`"${user}" joined the channel.`);
});
 
room.on('leave', (user) => {
  console.log(`"${user}" left the channel.`);
});
 
room.off('leave', (user) => {
  console.log(`"${user}" left the channel.`);
});

Using an EventMap

The type definitions for Node's EventEmitter are provided by @types/node. Sicne July 2024, @types/node includes a generic type for defining an event map (see Add generics to static members of EventEmitter). This event map lets you enforce strong typing between event names and their associated listener functions. By defining an event map, you can specify the exact parameter list that each listener should accept:

import EventEmitter from 'node:events';
 
interface ChatEvents {
  message: [from: string, content: string];
  join: [name: string];
  leave: [name: string];
}
 
const room = new EventEmitter<ChatEvents>();
 
room.on('message', (from, content) => {
  console.log(`${from}: ${content}`);
});
 
room.on('join', (user) => {
  console.log(`"${user}" joined the channel.`);
});
 
room.on('leave', (user) => {
  console.log(`"${user}" left the channel.`);
});
Back to Blog