Node's EventEmitter is one of the most used primitives in server-side JavaScript. It's also a refactoring minefield — every event name is a string, every payload is any, and nothing stops you from emitting an event nobody is listening to or wiring a listener that expects the wrong shape. TypeScript can enforce all of this at compile time, and the setup is simpler than most teams assume.
The Problem with Node's EventEmitter
The default API offers zero type safety on event names or payloads.
// ❌ TypeScript accepts all of this — none of it is checked
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
emitter.on("user:created", (user: { id: string; email: string }) => {
sendWelcomeEmail(user.email);
});
// Typo — listener above never fires, no compile error, no runtime warning
emitter.emit("user:create", { id: "1", email: "test@example.com" });
// Wrong payload shape — crashes inside the listener at runtime
emitter.emit("user:created", { userId: "1", address: "123 Main St" });These bugs survive code review, pass linting, and surface only in production. When an emitter crosses module boundaries — emitted in a service layer, consumed in a handler — the distance between cause and crash makes them expensive to diagnose.
Designing an Event Map
The foundation of a typed emitter is an interface that maps event names to payload types. Every event your system can fire gets an entry here.
// events.ts — single source of truth for your event contract
interface AppEvents {
"user:created": { id: string; email: string; createdAt: Date };
"user:deleted": { id: string; reason: "self" | "admin" | "inactivity" };
"order:placed": { orderId: string; userId: string; totalCents: number };
"order:fulfilled": { orderId: string; trackingCode: string };
"payment:failed": { orderId: string; code: string; retryable: boolean };
}This map becomes the type parameter you thread through your emitter. Adding a new event means adding one entry here — every call site that references the wrong name or wrong shape gets a compiler error automatically.
Building the Typed Emitter
Start with the interface, then implement it.
type EventHandler<T> = (payload: T) => void | Promise<void>;
interface TypedEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean;
listenerCount<K extends keyof Events>(event: K): number;
}
class TypedEventEmitter<Events extends Record<string, unknown>>
implements TypedEmitter<Events>
{
private handlers = new Map<keyof Events, Set<EventHandler<unknown>>>();
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as EventHandler<unknown>);
return this;
}
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
this.handlers.get(event)?.delete(handler as EventHandler<unknown>);
return this;
}
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
const wrapper: EventHandler<Events[K]> = (payload) => {
this.off(event, wrapper);
return handler(payload);
};
return this.on(event, wrapper);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean {
const listeners = this.handlers.get(event);
if (!listeners || listeners.size === 0) return false;
for (const handler of listeners) {
handler(payload);
}
return true;
}
listenerCount<K extends keyof Events>(event: K): number {
return this.handlers.get(event)?.size ?? 0;
}
}The generic constraint K extends keyof Events is the key insight. It tells TypeScript that the handler argument to on must be compatible with whatever payload type K maps to in Events. Typos in event names fail at the call site, not at runtime.
Now call sites look like this:
// ✅ Payload type is fully inferred — no annotation needed on the handler
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("user:created", (user) => {
// user: { id: string; email: string; createdAt: Date }
sendWelcomeEmail(user.email);
});
// ✅ Compile error: Argument of type '"user:create"' is not assignable to keyof AppEvents
// emitter.emit("user:create", { id: "1", email: "test@example.com" });
// ✅ Compile error: Object literal may only specify known properties
// emitter.emit("user:created", { userId: "1" });
emitter.emit("user:created", {
id: "usr_01j2k",
email: "new@example.com",
createdAt: new Date(),
});Handling Async Listeners Safely
Node's built-in EventEmitter silently drops Promise rejections from listeners — they become unhandled rejections with no context about which event caused them. Encoding a real strategy into emit costs almost nothing.
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean {
const listeners = this.handlers.get(event);
if (!listeners || listeners.size === 0) return false;
for (const handler of listeners) {
try {
const result = handler(payload);
if (result instanceof Promise) {
result.catch((err: unknown) => {
// Surface async errors with event context instead of swallowing them
console.error(
`Unhandled async error in listener for "${String(event)}":`,
err,
);
});
}
} catch (err) {
// Prevent one bad synchronous handler from breaking remaining listeners
console.error(
`Synchronous error in listener for "${String(event)}":`,
err,
);
}
}
return true;
}Wrapping each invocation in try/catch prevents one bad handler from silently aborting remaining listeners on the same event. Whether you log, re-emit to an error channel, or rethrow depends on your error strategy — but swallowing is never the right default.
Wrapping Third-Party Emitters
You don't always control the emitter. Database drivers, message queue clients, and file watchers frequently expose raw EventEmitter instances. Wrap them at the boundary instead of spreading untyped .on() calls across your codebase.
import type { Consumer } from "kafkajs";
interface KafkaConsumerEvents {
message: { topic: string; partition: number; value: Buffer | null };
error: { error: Error };
rebalancing: { type: "assign" | "revoke" };
}
function wrapKafkaConsumer(consumer: Consumer): TypedEmitter<KafkaConsumerEvents> {
const emitter = new TypedEventEmitter<KafkaConsumerEvents>();
consumer.on(consumer.events.GROUP_JOIN, ({ payload }) => {
emitter.emit("rebalancing", { type: "assign" });
});
consumer.on(consumer.events.CRASH, ({ payload }) => {
emitter.emit("error", { error: payload.error });
});
return emitter;
}The wrapper converts loosely-typed upstream events into your typed contract. Consumers of wrapKafkaConsumer work entirely through the typed interface — they never touch the raw Consumer object or its string-keyed event constants.
Key Takeaways
- Event maps are your contract — define them once and let the compiler enforce every call site. Adding an event is one line; removing one surfaces every stale reference automatically.
K extends keyof Eventsis the pattern — this generic constraint appears in typed emitters, router builders, store selectors, and anywhere you want exhaustive key checking. Internalize it.- Implement
oncein terms ofonandoff— keeps memory management consistent and avoids divergent cleanup logic. - Async errors need an explicit strategy — returning a Promise from a listener doesn't mean rejections propagate anywhere useful. Decide on a policy and encode it directly into
emit. - Wrap third-party emitters at the integration boundary — raw library events are seams, not internal domain events. Wrapping them decouples your code from library-specific constants and makes the event contract explicit.



