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 TEventscaptura erros de digitação e incompatibilidades de estrutura antes que seu código seja executado. - O
@types/nodemoderno 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
Discover how at OpenReplay.com.
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
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.
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 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.
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.
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.