Destino Dev

Blog

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:

  1. Usar strings livres para nomes de evento: quebra autocompletar e permite inconsistências.
  2. Definir EventMap com tipos muito genéricos (ex: any), que remove proteção.
  3. Perder o this ao registrar métodos; por isso usamos .bind(instance).
  4. Esquecer de tipar corretamente emit e on, criando pontos de escape para any.
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, e conditional types.
  • Decorators e metadata (quando usar Reflect e quando evitar).
  • Design de contratos: separar EventMap por bounded contexts.

Recursos concretos:

  1. Documentação oficial TypeScript (tipos utilitários e generics).
  2. Artigos sobre pattern Event Sourcing e CQRS — para entender quando eventos devem ter esquema rígido.
  3. 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