Check event names and their payloads at compile time in a subclass of EventEmitter
A service bus extends Node's EventEmitter. Inherited on and emit take a string | symbol name and any[] arguments, so a typo in a name and a listener whose arguments no longer match the emitter both compile and fail silently at runtime.
Requirements: the name must be restricted to the keys of AppEvents; a listener's parameters and the arguments of emit must both come from that name's tuple; emit("shutdown") must take no further argument; no any and no cast at any call site; no global augmentation of EventEmitter; dispatch must still go through the base class, so runtime behaviour is unchanged.
import { EventEmitter } from "node:events";
interface AppEvents {
"user:created": [user: { id: string }];
"job:failed": [jobId: string, error: Error];
"shutdown": [];
}
class TypedEmitter<E extends Record<keyof E, unknown[]>> extends EventEmitter {
// your code here
}
const bus = new TypedEmitter<AppEvents>();
Write the implementation.
Make the subclass generic over a registry of name to argument tuple; override on and emit as generic in K extends keyof E: event: K, ...args: E[K]. An unknown name or wrong payload fails to compile; the one cast stays inside.
- ✗Believing an override may not narrow the base
onandemitparameter types - ✗Typing one payload shape shared by every event instead of a tuple per name
- ✗Global-augmenting
EventEmitter, so every emitter in the process shares one event map
- →How do you keep
onceandoffin step, so a listener removed by reference still type-checks? - →What changes if a name maps to a single payload object instead of a tuple of arguments?
The solution
import { EventEmitter } from "node:events";
interface AppEvents {
"user:created": [user: { id: string }];
"job:failed": [jobId: string, error: Error];
"shutdown": [];
}
type Listener<A extends unknown[]> = (...args: A) => void;
class TypedEmitter<E extends Record<keyof E, unknown[]>> extends EventEmitter {
override on<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.on(event, listener as Listener<unknown[]>); // ← the one cast
}
override once<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.once(event, listener as Listener<unknown[]>);
}
override off<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.off(event, listener as Listener<unknown[]>);
}
override emit<K extends keyof E & string>(event: K, ...args: E[K]): boolean {
return super.emit(event, ...args);
}
}
Usage:
const bus = new TypedEmitter<AppEvents>();
bus.on("job:failed", (jobId, error) => {
// ^ "user:created" | "job:failed" | "shutdown"
// ^ jobId: string, error: Error — inferred, no annotations
});
bus.emit("shutdown"); // OK — the tuple is empty
bus.emit("user:created", { id: "u1" }); // OK
bus.emit("user:created"); // Expected 2 arguments, but got 1
bus.emit("user:crated", { id: "u1" }); // Argument of type '"user:crated"' is not assignable
Why the override is allowed at all
The base declares on(event: string | symbol, listener: (...args: any[]) => void): this, and the subclass narrows the name to K. That is accepted because method parameters are checked bivariantly: strictFunctionTypes tightens the check only for properties of function type, never for declared methods. The whole pattern rests on exactly that allowance.
There is precisely one unsound spot — the listener cast inside the class. It is honest: the base EventEmitter really does dispatch unknown[], and the guarantee that a tuple matches its name is held by the on and emit signatures every caller must pass through.
The E extends Record<keyof E, unknown[]> constraint stops anyone declaring an event whose payload is not a tuple — otherwise ...args: E[K] would not compile.