Saltar al contenido

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.

5 min de lectura
Código TypeScript que muestra una interfaz de emisor de eventos fuertemente tipada con restricciones genéricas y tipos de payload inferidos

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.

tstypescript
// ❌ 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í.

tstypescript
// 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.

tstypescript
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í:

tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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

  1. 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.
  2. K extends keyof Events es 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.
  3. Implementa once en términos de on y off — mantiene la gestión de memoria consistente y evita lógicas de limpieza divergentes.
  4. 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.
  5. 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.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX