12k
All articles

Emissores de Eventos Type-Safe em TypeScript

Event emitters com type safety em TypeScript: use event maps, um emitter genérico ou o EventEmitter do Node.js para pegar typos e payloads na compilação.

OpenReplay Team
OpenReplay Team
Emissores de Eventos Type-Safe em TypeScript

Você já passou por isso: um erro de digitação no nome de um evento, um payload incompatível e, de repente, um listener que nunca dispara silenciosamente. O bug leva 30 minutos para rastrear, e a correção é de um único caractere. Emissores de eventos tipados em TypeScript eliminam toda essa categoria de problema ao mover o erro do tempo de execução para o tempo de compilação.

Este artigo mostra como funciona o padrão de mapa de eventos do TypeScript, quando construir sua própria abstração versus usar o que já existe, e alguns padrões práticos que vale a pena conhecer.

Principais Conclusões

  • Uma interface de mapa de eventos atua como uma única fonte de verdade, vinculando cada nome de evento ao seu tipo de payload exato em tempo de compilação.
  • Uma classe emissora genérica leve usando K extends keyof TEvents captura erros de digitação e incompatibilidades de estrutura antes que seu código seja executado.
  • O @types/node moderno já suporta mapas de eventos tipados, então uma classe personalizada nem sempre é necessária.
  • Sempre combine o registro de listeners com limpeza explícita para evitar vazamentos de memória em serviços de longa duração e componentes de UI.

Por Que Emissores de Eventos Type-Safe São Importantes

A abordagem não tipada se parece com isso:

import { EventEmitter } from 'events'

const emitter = new EventEmitter()

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

emitter.emit('userLoggedin', { name: 'Alice' }) // erro de digitação — sem erro

Neste uso não tipado, o TypeScript não capturará a diferença de capitalização entre userLoggedIn e userLoggedin. O listener nunca dispara, e nada lhe diz o porquê.

O Padrão de Mapa de Eventos do TypeScript

A correção começa com uma interface simples — um mapa de eventos — onde cada chave é um nome de evento e cada valor é seu tipo de payload:

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

Esta interface se torna a única fonte de verdade para tudo que seu emissor pode fazer.

Construindo um Emissor Personalizado Fortemente Tipado

Aqui está uma classe emissora tipada mínima mas 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)
  }
}

O mecanismo chave é K extends keyof TEvents. Quando você chama emit('userLoggedIn', ...), o TypeScript restringe K para exatamente 'userLoggedIn' e verifica se o payload corresponde a { userId: string, timestamp: Date }. Um erro de digitação ou estrutura errada se torna um erro de compilação.

const emitter = new TypedEventEmitter<UserEvents>()

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

emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ erro de compilação
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ timestamp ausente

Usando EventEmitter do Node.js com Type Safety

O @types/node moderno expõe EventEmitter como uma classe genérica, então você pode frequentemente obter eventos tipados diretamente sem escrever um 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)

Uma ressalva importante: o sistema de eventos do Node é síncrono por padrão — os listeners executam na ordem de registro antes que emit retorne. Além disso, o evento 'error' tem comportamento especial em tempo de execução: se emitido sem nenhum listener anexado, o Node lança uma exceção não capturada. Utilitários auxiliares como events.once() podem não preservar totalmente os tipos do seu mapa de eventos, então a segurança de tipos pode degradar nesses casos.

Alternativas e Bibliotecas

Em contextos de navegador, EventTarget com CustomEvent é a opção nativa, mas tipar subclasses personalizadas de EventTarget em TypeScript é desajeitado — as restrições genéricas não se alinham de forma limpa com CustomEvent<T>.

Bibliotecas como mitt e eventemitter3 oferecem emissores tipados prontos para uso e valem a pena considerar se você quiser uma implementação testada em batalha em vez de criar a sua própria. Elas são opcionais, não obrigatórias — o padrão em si é direto o suficiente para ser implementado diretamente.

Limpeza Prática: Evitando Vazamentos de Memória

Sempre remova listeners quando eles não forem mais necessários. Listeners esquecidos são uma das fontes mais comuns de vazamentos de memória em código orientado a eventos:

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

emitter.on('userLoggedOut', handler)

// Mais tarde, na limpeza:
emitter.off('userLoggedOut', handler)

No React, combine isso com a limpeza do useEffect. Em qualquer serviço de longa duração, documente qual componente é responsável pela remoção dos listeners.

Conclusão

O padrão de mapa de eventos do TypeScript custa uma definição de interface antecipadamente. O que você recebe em troca é a aplicação em tempo de compilação de nomes de eventos e estruturas de payload em toda a sua base de código — sem mais incompatibilidades silenciosas, sem mais callbacks tipados como any, e IntelliSense completo em cada listener. Para qualquer sistema onde eventos cruzam fronteiras de módulos, essa troca vale a pena fazer imediatamente.

Perguntas Frequentes

Posso usar um emissor de eventos tipado com bibliotecas Node.js existentes que esperam um EventEmitter padrão?

Sim. Como seu emissor tipado ainda estende ou usa a API EventEmitter do Node, ele permanece compatível com qualquer código que aceite uma instância de EventEmitter. As restrições de tipo são aplicadas apenas em tempo de compilação e não produzem sobrecarga em tempo de execução, então as bibliotecas downstream continuam a funcionar como esperado.

Como lidar com eventos que não carregam payload algum?

Defina o valor do evento como void no seu mapa de eventos, por exemplo statusCheck: void. Em seguida, ajuste sua assinatura de emit para tornar o parâmetro payload opcional quando o tipo for void. Isso mantém o local de chamada limpo — você pode chamar emit('statusCheck') sem passar um argumento.

O que acontece se dois módulos definirem mapas de eventos conflitantes para o mesmo emissor?

O TypeScript sinalizará o conflito onde quer que os tipos sejam incompatíveis. A abordagem mais segura é definir uma única interface de mapa de eventos compartilhada em um módulo comum e importá-la em todos os lugares. Isso mantém todos os produtores e consumidores alinhados com uma única fonte de verdade.

Um emissor de eventos tipado é uma boa opção para comunicação entre micro-frontends?

Pode funcionar bem para mensagens leves dentro da página entre micro-frontends. No entanto, à medida que o número de equipes independentes cresce, considere combinar o padrão com um pacote de schema compartilhado ou etapa de geração de código para que o mapa de eventos permaneça sincronizado entre aplicações implantadas separadamente.

Open-source session replay

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.