Emisores de eventos con tipado seguro en TypeScript
Sustituye el EventEmitter poco tipado de Node por una alternativa genérica que detecta nombres de eventos y payloads incorrectos al compilar.

El EventEmitter de Node es una de las primitivas más utilizadas en JavaScript del lado del servidor. También es un campo minado para el refactoring: cada nombre de evento es una cadena de texto, cada payload es any, y nada te impide emitir un evento que nadie escucha o conectar un listener que espera una forma de datos incorrecta. TypeScript puede validar todo esto en tiempo de compilación, y la configuración es más sencilla de lo que la mayoría de los equipos supone.
El problema del EventEmitter de Node
La API por defecto no ofrece ninguna seguridad de tipos ni en los nombres de eventos ni en los 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" });Estos errores sobreviven a la revisión de código, pasan el linting y solo salen a la luz en producción. Cuando un emisor cruza los límites de un módulo (se emite en una capa de servicio y se consume en un handler), la distancia entre la causa y el fallo hace que sean costosos de diagnosticar.
Diseñando un mapa de eventos
La base de un emisor tipado es una interfaz que mapea los nombres de los eventos a sus tipos de payload. Cada evento que tu sistema pueda disparar tiene una entrada aquí.
// 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 };
}Este mapa se convierte en el parámetro de tipo que propagas a través de tu emisor. Agregar un evento nuevo implica añadir una entrada aquí: cualquier punto de llamada que use un nombre o una forma incorrecta recibe automáticamente un error del compilador.
Construyendo el emisor tipado
Empieza por la interfaz y luego impleméntala.
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;
}
}La restricción genérica K extends keyof Events es la clave de todo esto. Le indica a TypeScript que el argumento handler de on debe ser compatible con el tipo de payload al que K corresponda dentro de Events. Los errores de tipeo en los nombres de eventos fallan en el punto de llamada, no en tiempo de ejecución.
Ahora los puntos de llamada se ven así:
// ✅ 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(),
});Manejo seguro de listeners asíncronos
El EventEmitter integrado de Node descarta silenciosamente los rechazos de Promise que provienen de los listeners: se convierten en unhandled rejections sin ningún contexto sobre qué evento los provocó. Codificar una estrategia real dentro de emit cuesta casi nada.
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;
}Envolver cada invocación en un try/catch evita que un handler defectuoso interrumpa en silencio el resto de los listeners de ese mismo evento. Registrar el error, reemitirlo a un canal de errores o volver a lanzarlo depende de tu estrategia de manejo de errores, pero ignorarlo silenciosamente nunca es la opción correcta por defecto.
Envolviendo emisores de terceros
No siempre tienes control sobre el emisor. Los drivers de bases de datos, los clientes de colas de mensajes y los file watchers suelen exponer instancias de EventEmitter sin tipar. Envuélvelos en el límite del sistema en lugar de esparcir llamadas .on() sin tipar por toda tu base de código.
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;
}El wrapper convierte los eventos de origen, débilmente tipados, en tu contrato tipado. Quienes consumen wrapKafkaConsumer trabajan exclusivamente a través de la interfaz tipada: nunca tocan el objeto Consumer original ni sus constantes de eventos basadas en strings.
Puntos clave
- Los mapas de eventos son tu contrato — defínelos una sola vez y deja que el compilador valide cada punto de llamada. Agregar un evento es una línea; eliminar uno saca a la luz automáticamente cada referencia obsoleta.
K extends keyof Eventses el patrón — esta restricción genérica aparece en emisores tipados, constructores de routers, selectores de store y en cualquier lugar donde quieras verificación exhaustiva de claves. Interiorízalo.- Implementa
onceen términos deonyoff— mantiene la gestión de memoria consistente y evita lógicas de limpieza divergentes. - Los errores asíncronos necesitan una estrategia explícita — que un listener devuelva una Promise no significa que sus rechazos se propaguen a ningún lado útil. Define una política y codifícala directamente en
emit. - Envuelve los emisores de terceros en el límite de integración — los eventos de librerías externas son costuras, no eventos de dominio internos. Envolverlos desacopla tu código de las constantes específicas de cada librería y deja explícito el contrato de eventos.


