EventEmitter type-safe em TypeScript com generics e mapped types
Por John Pires · 03 de abril de 2026

Por que aprender isso em 2026
Arquiteturas baseadas em eventos continuam dominando aplicações front-end, back-end e microserviços. Um EventEmitter é um padrão simples e poderoso para desacoplar produtores e consumidores; no entanto, sem tipagem adequada, eventos se tornam fonte de bugs silenciosos. Em 2026, com código cada vez mais distribuído e equipes maiores, garantir type-safety em sinais entre módulos não é luxo — é necessidade.
Este capítulo apresenta a motivação técnica de forma direta: entender como usar generics e mapped types para modelar eventos e cargas (payloads) em TypeScript. A analogia: pense em eventos como cartas em envelopes, e a tipagem é o selo que garante que cada envelope tem o formato esperado antes de ser aberto.
Contexto atual
Frameworks e bibliotecas oferecem emitters genéricos (Node.js EventEmitter, RxJS Subjects, etc.), mas frequentemente aceitam any ou strings soltas para nomes de evento. Quando o remetente envia um objeto e o receptor espera outro formato, o TypeScript não protege sem contratos explícitos. Aprender a criar um EventEmitter type-safe resolve isso e ensina técnicas genéricas úteis em outras áreas.
Benefícios práticos
Com uma implementação type-safe você ganha:
- Autocompletar e validação em IDEs para nomes de evento.
- Verificação de payload em tempo de compilação, evitando crashes em produção.
- Documentação automática dos contratos de eventos via tipos.
Dica: começar com uma interface que descreve o 'EventMap' do seu domínio é o passo mais eficaz para migrar código inseguro.
Configuração do ambiente
Vamos preparar um ambiente mínimo: Node 18+, npm/yarn, e TypeScript. Para efeitos de aprendizado, usaremos TypeScript puro, sem frameworks, e ativaremos opções que maximizam a segurança de tipos.
Instalação do TypeScript
Crie um diretório novo e inicialize um projeto:
// cria npm e instala typescript
npm init -y
npm install --save-dev typescript ts-node @types/node
O comando acima instala ts-node para executar arquivos .ts rapidamente e as typings do Node. Em seguida, gere um arquivo tsconfig.json minimalista com opções recomendadas.
tsconfig e dicas
Um tsconfig bem configurado evita surpresas:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": false,
"skipLibCheck": true
}
}
O importante aqui é strict e experimentalDecorators — usaremos decoradores opcionais na seção do mini-projeto. Se não quiser decoradores, remova a opção, mas o exemplo também terá alternativa sem decorator.
Hello World progressivo
Neste bloco vamos construir três versões crescentes: (1) um emitter sem tipagem, (2) uma versão com generics simples e (3) uma versão com mapped types que impõe todo o contrato de eventos.
Primeira versão sem tipagem
Comece com uma implementação minimalista parecida com a do Node, mas em TypeScript com tipos fracos. O objetivo é demonstrar o problema.
// emitter-naive.ts
// O que faz: um emitter simples que aceita qualquer evento (string) e qualquer payload (any)
type Listener = (...args: any[]) => void;
class NaiveEmitter {
private listeners = new Map<string, Listener[]>();
on(event: string, fn: Listener) {
const arr = this.listeners.get(event) || [];
arr.push(fn);
this.listeners.set(event, arr);
}
emit(event: string, ...args: any[]) {
(this.listeners.get(event) || []).forEach(fn => fn(...args));
}
}
// Uso: aceita qualquer coisa, não há garantias
const emitter = new NaiveEmitter();
emitter.on('message', (payload) => console.log(payload.text.toUpperCase()));
emitter.emit('message', { text: 'olá' });
O que acontece: compila, mas se alguém emitir { body: 'x' } em vez de { text: 'x' }, o erro só aparecerá em tempo de execução. Em equipes, esse tipo de falha é comum e difícil de rastrear.
Versão type-safe com generics e mapped types
A solução é declarar um mapa de eventos e usar generics para ligar o nome do evento com o formato do payload. A seguir temos a versão recomendada.
// typed-emitter.ts
// O que faz: um emitter que usa um EventMap genérico para garantir tipos por evento
export interface EventMap { [event: string]: any }
export class TypedEventEmitter<EM extends EventMap> {
private listeners: { [K in keyof EM]?: Array<(payload: EM[K]) => void> } = {} as any;
on<K extends keyof EM>(event: K, listener: (payload: EM[K]) => void): void {
const arr = this.listeners[event] || [];
arr.push(listener);
this.listeners[event] = arr as any;
}
off<K extends keyof EM>(event: K, listener: (payload: EM[K]) => void): void {
const arr = this.listeners[event] || [];
this.listeners[event] = arr.filter(l => l !== listener);
this.listeners[event] = arr as any;
}
emit<K extends keyof EM>(event: K, payload: EM[K]): void {
(this.listeners[event] || []).forEach(fn => fn(payload));
}
}
// Uso seguro
interface MyEvents {
message: { from: string; text: string };
join: { userId: string };
}
const te = new TypedEventEmitter<MyEvents>();
te.on('message', p => console.log(p.from, p.text));
te.emit('message', { from: 'alice', text: 'oi' });
O que acontece: a assinatura on('message', ...) exige o formato correto do payload. Se você tentar emit('message', { body: 'x' }), o compilador aponta o erro. Essa técnica usa generics para ligar nomes de eventos a tipos, e um mapped type para modelar o armazenamento interno.
Mini-projeto prático
Vamos construir um mini chat que demonstra o emitter type-safe em contexto prático, acrescentando helpers, type guards e um decorator opcional para registrar handlers automaticamente.
Requisitos e tipos
Defina os eventos do chat em um EventMap. Isso é o contrato do sistema — pense como o protocolo entre frontend e backend.
// events.ts
export interface ChatEvents {
message: { from: string; text: string };
join: { userId: string };
leave: { userId: string; reason?: string };
}
O que acontece: ao centralizar tipos, todo o app pode importar ChatEvents e ter autocompletar e validação para eventos do chat.
Implementação com decorators e type guards
Aqui implementamos o emitter e duas utilidades: um decorator @Listen('evento') que marca métodos como listeners, e uma função registerListeners que registra esses métodos no emitter. Isso é útil para evitar chamadas manuais de on em cada instância.
// emitter-with-decorator.ts
// O que faz: TypedEventEmitter com suporte simples a um decorator que marca métodos
import { ChatEvents } from './events';
type AnyFn = (...args: any[]) => any;
export class TypedEventEmitter<EM extends Record<string, any>> {
private listeners: { [K in keyof EM]?: Array<(payload: EM[K]) => void> } = {} as any;
on<K extends keyof EM>(event: K, listener: (payload: EM[K]) => void) {
const arr = this.listeners[event] || [];
arr.push(listener);
this.listeners[event] = arr as any;
}
emit<K extends keyof EM>(event: K, payload: EM[K]) {
(this.listeners[event] || []).forEach(fn => fn(payload));
}
}
// Decorator simples: grava a intenção no método
export function Listen<E extends string>(event: E) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// marca o método com metadado simples
const key = '__listeners__';
target[key] = target[key] || [];
target[key].push({ method: propertyKey, event });
};
}
// Registra todos os métodos marcados como listeners na instância
export function registerListeners<EM extends Record<string, any>>(emitter: TypedEventEmitter<EM>, instance: any) {
const key = '__listeners__';
const proto = Object.getPrototypeOf(instance);
const list = proto[key] as Array<{ method: string; event: string }> | undefined;
if (!list) return;
list.forEach(item => {
const fn: AnyFn = instance[item.method].bind(instance);
// uso de any aqui é controlado pelo EM no uso concreto
emitter.on(item.event as keyof EM, (payload: any) => fn(payload));
});
}
// Uso prático
const emitter = new TypedEventEmitter<ChatEvents>();
class ChatHandler {
@Listen('message')
onMessage(payload: ChatEvents['message']) {
console.log('Mensagem de', payload.from + ':', payload.text);
}
@Listen('join')
onJoin(payload: ChatEvents['join']) {
console.log('Entrou', payload.userId);
}
}
const handler = new ChatHandler();
registerListeners(emitter, handler);
emitter.emit('message', { from: 'bob', text: 'oi' });
O que acontece: o decorator não precisa de reflect-metadata; ele apenas registra informações no protótipo. Em tempo de execução, registerListeners lê esse registro e chama on no emitter, vinculando métodos ao contexto da instância.
Erros comuns e armadilhas
Ao trabalhar com typed emitters, veja os problemas mais frequentes:
- Usar strings livres para nomes de evento: quebra autocompletar e permite inconsistências.
- Definir EventMap com tipos muito genéricos (ex:
any), que remove proteção. - Perder o
thisao registrar métodos; por isso usamos.bind(instance). - Esquecer de tipar corretamente
emiteon, criando pontos de escape paraany.
Regra prática: atualize o EventMap sempre que adicionar ou remover um evento. Automatizar testes que emitem e validam payloads previne regressões.
Outra armadilha é tentar tipar eventos dinâmicos que só aparecem em runtime. Nesses casos, combine validação em runtime (type guards) com tipos mais amplos e documentados.
// exemplo de type guard simples
function isMessage(payload: any): payload is ChatEvents['message'] {
return payload && typeof payload.from === 'string' && typeof payload.text === 'string';
}
// uso: valida em runtime antes de processar
emitter.on('message', p => {
if (!isMessage(p)) {
console.warn('payload inválido', p);
return;
}
// aqui o compilador sabe que p é message
});
O que acontece: type guards protegem contra inputs externos (por exemplo, do network) mesmo quando o compilador já verifica os emissores locais.
Recursos para continuar estudando
Para aprofundar, foque em tópicos adjacentes que fortalecem a abordagem type-safe:
- Mapped types avançados:
Record,Partial,Required, econditional types. - Decorators e metadata (quando usar Reflect e quando evitar).
- Design de contratos: separar EventMap por bounded contexts.
Recursos concretos:
- Documentação oficial TypeScript (tipos utilitários e generics).
- Artigos sobre pattern Event Sourcing e CQRS — para entender quando eventos devem ter esquema rígido.
- Repositórios open-source com emitters type-safe (procure por implementações de TypedEventEmitter em GitHub).
Conclusão prática
Implementar um EventEmitter type-safe com generics e mapped types reduz bugs, melhora a ergonomia do desenvolvimento e torna contratos entre módulos explícitos. A técnica central é simples: declarar um EventMap e usar generics para ligar nomes de evento a payloads.
Comece migrando um emitter local em um módulo pequeno, defina o EventMap e altere on/emit. Em seguida, aplique type guards nas fronteiras do sistema (onde dados entram). Se desejar automação, o padrão de decorator mostrado ajuda a reduzir boilerplate.
Por fim, pratique: transforme três eventos aleatórios do seu projeto atual em um EventMap e observe os erros de compilação que o TypeScript revela — esses erros apontam exatamente para onde você precisa corrigir contratos.
Resumo rápido: declare o contrato (EventMap), implemente um emitter genérico tipado, use type guards para entradas inseguras, e aplique decoradores com cautela. Esses passos entregam um sistema de eventos mais robusto para 2026 e além.
Assuntos: TypeScript, generics, mapped-types, event-emitter, type-safety
Leia também
Feature flags type-safe em TypeScript: configuração, generics e type guards
Aprenda a implementar feature flags type-safe em TypeScript: defina keys literais, use generics, type guards e decorators para evitar typos e validar…
IDs nominais em TypeScript: branded types, type guards e generics passo a passo
Aprenda a proteger IDs em TypeScript com branded types, type guards e generics. Do jeito errado (string genérico) ao jeito certo (fábrica + validação), com…
Otimização de consultas PostgreSQL com Prisma: EXPLAIN ANALYZE, índices e pool de conexões
Guia avançado para otimizar consultas PostgreSQL com Prisma: como usar EXPLAIN ANALYZE, escolher índices (btree/gin/expressões/partial/include) e operar…
Config type-safe em Node.js com TypeScript: Zod, generics e utility types
Aprenda a fazer configuração type-safe em Node.js com TypeScript usando Zod, generics e utility types. Passo a passo prático para validar e inferir tipos do…
Converter JSON snake_case para camelCase com TypeScript e Generics
Aprenda passo a passo a converter JSON snake_case para camelCase usando TypeScript: funções runtime recursivas, tipos genéricos DeepCamelCase e boas práticas…
DTOs type-safe em TypeScript: decorators, generics e type guards passo a passo
Aprenda a transformar JSON em DTOs type-safe com TypeScript usando decorators, generics e type guards. Guia passo a passo para iniciantes: configuração…