Back

Emisores de Eventos con Seguridad de Tipos en TypeScript

Emisores de Eventos con Seguridad de Tipos en TypeScript

Ya has estado ahí: un error tipográfico en el nombre de un evento, una carga útil que no coincide, y de repente un listener nunca se dispara silenciosamente. El bug tarda 30 minutos en rastrearse, y la solución es un solo carácter. Los emisores de eventos tipados en TypeScript eliminan toda esta categoría de problemas al mover el error del tiempo de ejecución al tiempo de compilación.

Este artículo te muestra cómo funciona el patrón de mapa de eventos en TypeScript, cuándo construir tu propia abstracción versus usar lo que ya existe, y algunos patrones prácticos que vale la pena conocer.

Puntos Clave

  • Una interfaz de mapa de eventos actúa como una única fuente de verdad, vinculando cada nombre de evento a su tipo de carga útil exacto en tiempo de compilación.
  • Una clase emisora genérica ligera que usa K extends keyof TEvents detecta errores tipográficos y discrepancias de forma antes de que tu código se ejecute.
  • Los @types/node modernos ya soportan mapas de eventos tipados, por lo que no siempre es necesaria una clase personalizada.
  • Siempre empareja el registro de listeners con limpieza explícita para prevenir fugas de memoria en servicios de larga duración y componentes de UI.

Por Qué Importan los EventEmitters con Seguridad de Tipos

El enfoque sin tipos se ve así:

import { EventEmitter } from 'events'

const emitter = new EventEmitter()

emitter.on('userLoggedIn', (user) => {
  console.log(user.name) // user es `any`
})

emitter.emit('userLoggedin', { name: 'Alice' }) // error tipográfico — sin error

En este uso sin tipos, TypeScript no detectará la diferencia de mayúsculas entre userLoggedIn y userLoggedin. El listener nunca se dispara, y nada te dice por qué.

El Patrón de Mapa de Eventos en TypeScript

La solución comienza con una interfaz simple — un mapa de eventos — donde cada clave es un nombre de evento y cada valor es su tipo de carga útil:

interface UserEvents {
  userLoggedIn: { userId: string, timestamp: Date }
  userLoggedOut: { userId: string }
  profileUpdated: { userId: string, changes: Record<string, unknown> }
}

Esta interfaz se convierte en la única fuente de verdad para todo lo que tu emisor puede hacer.

Construyendo un Emisor Personalizado Fuertemente Tipado

Aquí hay una clase emisora tipada mínima pero completa:

type EventCallback<T> = (payload: T) => void

class TypedEventEmitter<TEvents extends Record<string, unknown>> {
  private listeners: {
    [K in keyof TEvents]?: EventCallback<TEvents[K]>[]
  } = {}

  on<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    (this.listeners[event] ??= []).push(callback)
  }

  off<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    const cbs = this.listeners[event]
    if (cbs) this.listeners[event] = cbs.filter(cb => cb !== callback)
  }

  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    this.listeners[event]?.forEach(cb => cb(payload))
  }

  once<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    const wrapper: EventCallback<TEvents[K]> = (payload) => {
      this.off(event, wrapper)
      callback(payload)
    }
    this.on(event, wrapper)
  }
}

El mecanismo clave es K extends keyof TEvents. Cuando llamas a emit('userLoggedIn', ...), TypeScript reduce K exactamente a 'userLoggedIn' y verifica que la carga útil coincida con { userId: string, timestamp: Date }. Un error tipográfico o una forma incorrecta se convierte en un error de compilación.

const emitter = new TypedEventEmitter<UserEvents>()

emitter.on('userLoggedIn', ({ userId, timestamp }) => {
  console.log(userId, timestamp) // completamente tipado
})

emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ error de compilación
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ falta timestamp

Usando EventEmitter de Node.js con Seguridad de Tipos

Los @types/node modernos exponen EventEmitter como una clase genérica, por lo que a menudo puedes obtener eventos tipados directamente sin escribir un wrapper.

import { EventEmitter } from 'events'

interface AppEvents {
  'user:login': [userId: string, email: string]
  'data:sync': [recordCount: number]
}

const emitter = new EventEmitter<AppEvents>()

emitter.on('user:login', (userId, email) => {
  console.log(userId, email)
})

emitter.emit('data:sync', 42)

Una advertencia importante: el sistema de eventos de Node es síncrono por defecto — los listeners se ejecutan en orden de registro antes de que emit retorne. Además, el evento 'error' tiene un comportamiento especial en tiempo de ejecución: si se emite sin un listener adjunto, Node lanza una excepción no capturada. Las utilidades auxiliares como events.once() pueden no preservar completamente los tipos de tu mapa de eventos, por lo que la seguridad de tipos puede degradarse en esos casos.

Alternativas y Bibliotecas

En contextos de navegador, EventTarget con CustomEvent es la opción nativa, pero tipar subclases personalizadas de EventTarget en TypeScript es incómodo — las restricciones genéricas no se alinean limpiamente con CustomEvent<T>.

Bibliotecas como mitt y eventemitter3 ofrecen emisores tipados listos para usar y vale la pena considerarlas si quieres una implementación probada en batalla en lugar de crear la tuya propia. Son opcionales, no requeridas — el patrón en sí es lo suficientemente directo como para manejarlo directamente.

Limpieza Práctica: Evitando Fugas de Memoria

Siempre elimina los listeners cuando ya no sean necesarios. Los listeners olvidados son una de las fuentes más comunes de fugas de memoria en código basado en eventos:

const handler = ({ userId }: { userId: string }) => console.log(userId)

emitter.on('userLoggedOut', handler)

// Más tarde, en la limpieza:
emitter.off('userLoggedOut', handler)

En React, empareja esto con la limpieza de useEffect. En cualquier servicio de larga duración, documenta qué componente es responsable de la eliminación de listeners.

Conclusión

El patrón de mapa de eventos en TypeScript te cuesta una definición de interfaz por adelantado. Lo que obtienes a cambio es la aplicación en tiempo de compilación de nombres de eventos y formas de carga útil en toda tu base de código — no más discrepancias silenciosas, no más callbacks tipados como any, y autocompletado completo en cada listener. Para cualquier sistema donde los eventos crucen límites de módulos, ese intercambio vale la pena hacerlo inmediatamente.

Preguntas Frecuentes

Sí. Debido a que tu emisor tipado aún extiende o usa la API EventEmitter de Node, permanece compatible con cualquier código que acepte una instancia de EventEmitter. Las restricciones de tipo se aplican solo en tiempo de compilación y no producen sobrecarga en tiempo de ejecución, por lo que las bibliotecas descendentes continúan funcionando como se espera.

Define el valor del evento como void en tu mapa de eventos, por ejemplo statusCheck: void. Luego ajusta tu firma de emit para hacer que el parámetro de carga útil sea opcional cuando el tipo sea void. Esto mantiene limpio el sitio de llamada — puedes llamar emit('statusCheck') sin pasar un argumento.

TypeScript marcará el conflicto donde sea que los tipos sean incompatibles. El enfoque más seguro es definir una única interfaz de mapa de eventos compartida en un módulo común e importarla en todas partes. Esto mantiene a todos los productores y consumidores alineados contra una sola fuente de verdad.

Puede funcionar bien para mensajería ligera dentro de la página entre micro-frontends. Sin embargo, a medida que crece el número de equipos independientes, considera emparejar el patrón con un paquete de esquema compartido o un paso de generación de código para que el mapa de eventos permanezca sincronizado entre aplicaciones desplegadas por separado.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay