Pular para o conteúdo
TypeScript

Event Emitter Type-Safe em TypeScript

Monte um sistema de eventos que pega erros antes do código rodar. Generics + mapped types = autocomplete perfeito e zero bugs de tipagem.

Por que isso é importante

Event Emitter Type-Safe em TypeScript. Monte um sistema de eventos que pega erros antes do código rodar. Generics + mapped types = autocomplete perfeito e zero bugs de tipagem.

O Problema dos Eventos Sem Tipo

A implementação clássica de event emitter em JavaScript é top... até dar problema. Veja o cenário comum:

// JavaScript clássico - parece bom, mas é uma armadilha
class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(event, listener) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(listener);
  }

  emit(event, data) {
    if (!this.events[event]) return;
    this.events[event].forEach(listener => listener(data));
  }
}

// Uso que compila mas quebra em runtime
const emitter = new EventEmitter();

// Desenvolvedor A registra o listener
emitter.on('user:login', (user) => {
  console.log(`Welcome ${user.name}!`);
});

// Desenvolvedor B emite o evento (3 meses depois)
emitter.emit('user:logged-in', { id: 123, email: 'test@test.com' });
// Bug silencioso: nomes diferentes, listener nunca dispara

// Ou pior: tipos errados
emitter.emit('user:login', 'just a string');
// Runtime error: Cannot read property 'name' of undefined

Galera, esse código tem 3 problemas severos que só aparecem em produção:

  • Typos em nomes de eventos passam despercebidos - 'login' vs 'logged-in' são strings diferentes, nenhum erro
  • Payloads errados explodem em runtime - você espera objeto, recebe string, boom
  • Zero autocomplete - você não sabe quais eventos existem sem ler documentação desatualizada
  • Refactoring vira pesadelo - mudar nome de evento exige busca global manual e esperança

Em projetos grandes, vi sistemas inteiros com eventos que ninguém mais emite. Listeners órfãos consumindo memória. Payloads com estruturas que mudaram há 6 meses. É um cenário flamejante.

TypeScript resolve isso, mas não automaticamente. Você precisa construir as abstrações certas. Vamos montar um sistema onde o compilador trabalha pra você.

Mapeando Eventos com Generics

Primeira coisa: criar um mapa de tipos que define todos os eventos e seus payloads. É como um contrato que o TypeScript vai fiscalizar:

// EventMap define a estrutura de todos os eventos
type EventMap = {
  // Cada chave é um nome de evento
  // Cada valor é o tipo do payload
  'user:login': { id: number; email: string; name: string };
  'user:logout': { id: number };
  'post:created': { id: string; title: string; authorId: number };
  'post:deleted': { id: string };
  'notification:sent': { userId: number; message: string; type: 'info' | 'warning' | 'error' };
};

// Agora extraímos os nomes dos eventos
type EventName = keyof EventMap;
// Resultado: 'user:login' | 'user:logout' | 'post:created' | ...

// E criamos um type helper para pegar o payload de cada evento
type EventPayload = EventMap[T];
// EventPayload<'user:login'> = { id: number; email: string; name: string }
// EventPayload<'user:logout'> = { id: number }

Esse padrão é top porque centraliza a definição de todos os eventos. Quer adicionar um novo evento? Adiciona uma linha no EventMap. Quer mudar o payload? Muda ali e o TypeScript aponta todos os lugares que quebram.

A mágica está nos mapped types. Veja como usamos conditional types pra tornar isso ainda mais poderoso:

// Type helper avançado: permite eventos sem payload
type EventMap = {
  'user:login': { id: number; email: string; name: string };
  'user:logout': { id: number };
  'app:ready': void; // Evento sem dados
  'app:error': Error; // Evento com erro
};

// Listener type que se adapta ao payload
type EventListener = 
  EventMap[T] extends void
    ? () => void // Se payload é void, listener não recebe nada
    : (data: EventMap[T]) => void; // Caso contrário, recebe o payload

// Uso:
const loginListener: EventListener<'user:login'> = (data) => {
  // data é tipado como { id: number; email: string; name: string }
  console.log(data.email);
};

const readyListener: EventListener<'app:ready'> = () => {
  // Sem parâmetros, porque payload é void
  console.log('App pronto!');
};

Simples assim: mapped types deixam você criar tipos que se transformam baseado em outros tipos. É a base de qualquer sistema type-safe complexo.

Construindo o TypedEventEmitter

Agora vem a parte top: implementar o event emitter usando os tipos que criamos. A classe precisa garantir que só eventos válidos sejam emitidos e só payloads corretos sejam aceitos:

// TypedEventEmitter - a implementação completa
class TypedEventEmitter> {
  // Armazena os listeners de forma tipada
  private events: {
    [K in keyof TEventMap]?: Array<(data: TEventMap[K]) => void>;
  } = {};

  // Registra listener com validação de tipos
  on(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): void {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event]!.push(listener);
  }

  // Remove listener específico
  off(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): void {
    if (!this.events[event]) return;
    
    const index = this.events[event]!.indexOf(listener);
    if (index > -1) {
      this.events[event]!.splice(index, 1);
    }
  }

  // Emite evento com payload validado
  emit(
    event: K,
    data: TEventMap[K]
  ): void {
    if (!this.events[event]) return;
    
    // Chama todos os listeners com o payload
    this.events[event]!.forEach(listener => {
      try {
        listener(data);
      } catch (error) {
        console.error(`Error in listener for "${String(event)}":`, error);
      }
    });
  }

  // Remove todos os listeners de um evento
  removeAllListeners(event?: K): void {
    if (event) {
      delete this.events[event];
    } else {
      this.events = {};
    }
  }

  // Retorna número de listeners para um evento
  listenerCount(event: K): number {
    return this.events[event]?.length ?? 0;
  }
}

Olha o que tá rolando aqui. A classe é genérica sobre TEventMap. Isso significa que cada instância sabe exatamente quais eventos pode emitir e que dados cada evento carrega.

Repara nos generics nos métodos: . Isso força K a ser uma das chaves do mapa de eventos. Se você tentar usar um evento que não existe, TypeScript grita antes mesmo de compilar.

Veja o uso prático:

// Define o mapa de eventos da aplicação
type AppEvents = {
  'user:login': { id: number; email: string; name: string };
  'user:logout': { id: number };
  'post:created': { id: string; title: string };
};

// Cria instância tipada
const emitter = new TypedEventEmitter();

// Registra listener - TypeScript sabe o tipo do data
emitter.on('user:login', (data) => {
  // data é automaticamente tipado como { id: number; email: string; name: string }
  console.log(`Bem vindo, ${data.name}!`);
  console.log(`Email: ${data.email}`);
});

// Emite evento com payload correto
emitter.emit('user:login', {
  id: 123,
  email: 'user@example.com',
  name: 'João Silva'
});

// ❌ ERRO: Evento não existe
emitter.on('user:signup', (data) => {});
// TypeScript error: Argument of type '"user:signup"' is not assignable...

// ❌ ERRO: Payload errado
emitter.emit('user:login', { id: 123 });
// TypeScript error: Property 'email' is missing...

// ❌ ERRO: Tipo de campo errado
emitter.emit('user:login', {
  id: '123', // deveria ser number
  email: 'test@test.com',
  name: 'Test'
});
// TypeScript error: Type 'string' is not assignable to type 'number'

É isso aí. Todos esses erros aparecem no VS Code com squiggly lines vermelhas. Você não consegue nem commitar código quebrado. É validação em tempo de desenvolvimento, não em runtime quando usuário já tá no sistema.

Autocomplete e Validação Grátis

A parte mais viciante de usar event emitters tipados é o autocomplete. Você digita emitter.on(' e o VS Code mostra todos os eventos disponíveis. Não precisa lembrar nomes, não precisa consultar docs.

Mas não para por aí. Quando você seleciona um evento, o TypeScript já sabe o formato exato do payload. Digita (data) => e o IntelliSense mostra todas as propriedades disponíveis:

// Autocomplete em ação
const emitter = new TypedEventEmitter();

// 1. Digite emitter.on('
emitter.on('user:'
// VS Code mostra:
// - user:login
// - user:logout

// 2. Selecione o evento
emitter.on('user:login', (data) => {
  // 3. Digite data.
  data.
  // VS Code mostra:
  // - id: number
  // - email: string  
  // - name: string
  
  // Autocomplete funciona perfeitamente
  console.log(data.name.toUpperCase());
  console.log(data.email.includes('@'));
});

// 4. Na hora de emitir
emitter.emit('user:login', {
  // VS Code sugere as propriedades conforme você digita
  id: // sugere number
  email: // sugere string
  name: // sugere string
});

Cara, isso muda o jogo completamente. Você não perde tempo consultando docs ou procurando exemplos. Tudo que você precisa saber tá ali, inline, enquanto você coda.

E tem mais: refactoring fica trivial. Precisa mudar o nome de um evento? Rename symbol (F2 no VS Code) e pronto. Todos os usos atualizam automaticamente. Precisa adicionar um campo no payload? Adiciona no EventMap e o TypeScript aponta todos os lugares que precisam ser atualizados:

// Antes
type AppEvents = {
  'user:login': { id: number; email: string; name: string };
};

// Depois: adiciona campo opcional
type AppEvents = {
  'user:login': { 
    id: number; 
    email: string; 
    name: string;
    avatar?: string; // novo campo
  };
};

// Todos os emits continuam funcionando (campo opcional)
emitter.emit('user:login', {
  id: 123,
  email: 'test@test.com',
  name: 'Test'
}); // OK

// Mas agora você pode adicionar avatar
emitter.emit('user:login', {
  id: 123,
  email: 'test@test.com',
  name: 'Test',
  avatar: 'https://...' // novo campo usado
});

// Se fosse campo obrigatório:
type AppEvents = {
  'user:login': { 
    id: number; 
    email: string; 
    name: string;
    avatar: string; // obrigatório agora
  };
};

// TypeScript quebraria em TODOS os emits sem avatar
// Você veria exatamente quais lugares precisam ser atualizados

Essa é a diferença entre código que quebra em produção e código que nem compila se tiver erro. Prefiro mil vezes ver erro vermelho no editor do que bug report de cliente.

Patterns Avançados

Vamos além do básico. Tem situações onde você precisa de funcionalidades extras. Vou mostrar 4 patterns que uso direto em projetos reais.

1. Wildcard Listeners

Às vezes você quer escutar todos os eventos, tipo pra logging ou analytics:

class TypedEventEmitter> {
  private events: {
    [K in keyof TEventMap]?: Array<(data: TEventMap[K]) => void>;
  } = {};
  
  // Wildcard listeners que recebem todos os eventos
  private wildcardListeners: Array<
    (event: K, data: TEventMap[K]) => void
  > = [];

  // Método on normal...
  on(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): void {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event]!.push(listener);
  }

  // Novo: wildcard listener
  onAny(
    listener: (event: K, data: TEventMap[K]) => void
  ): void {
    this.wildcardListeners.push(listener);
  }

  // Emit atualizado pra chamar wildcards
  emit(
    event: K,
    data: TEventMap[K]
  ): void {
    // Chama listeners específicos
    if (this.events[event]) {
      this.events[event]!.forEach(listener => {
        try {
          listener(data);
        } catch (error) {
          console.error(`Error in listener for "${String(event)}":`, error);
        }
      });
    }
    
    // Chama wildcard listeners
    this.wildcardListeners.forEach(listener => {
      try {
        listener(event, data);
      } catch (error) {
        console.error(`Error in wildcard listener:`, error);
      }
    });
  }
}

// Uso: logger que captura tudo
emitter.onAny((event, data) => {
  console.log(`[EVENT] ${String(event)}:`, data);
  // Envia pra analytics, Sentry, etc
});

2. Once Method

Listener que dispara só uma vez e se auto-remove:

class TypedEventEmitter> {
  // ... código anterior ...

  // Listener que executa uma vez só
  once(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): void {
    // Cria wrapper que remove a si mesmo
    const TEventMap[K]) => {
      listener(data);
      this.off(event, onceWrapper);
    };
    
    this.on(event, onceWrapper);
  }
}

// Uso: espera primeira conexão
emitter.once('connection:established', (data) => {
  console.log('Conectado pela primeira vez!');
  // Executa apenas na primeira conexão
  // Conexões seguintes não disparam esse listener
});

3. Async Events

Suporte pra listeners assíncronos com Promise.all:

class TypedEventEmitter> {
  private events: {
    [K in keyof TEventMap]?: Array<
      (data: TEventMap[K]) => void | Promise
    >;
  } = {};

  // Emit síncrono normal
  emit(
    event: K,
    data: TEventMap[K]
  ): void {
    if (!this.events[event]) return;
    
    this.events[event]!.forEach(listener => {
      try {
        listener(data);
      } catch (error) {
        console.error(`Error in listener:`, error);
      }
    });
  }

  // Novo: emit assíncrono que espera todos os listeners
  async emitAsync(
    event: K,
    data: TEventMap[K]
  ): Promise {
    if (!this.events[event]) return;
    
    // Executa todos os listeners em paralelo
    await Promise.all(
      this.events[event]!.map(async (listener) => {
        try {
          await listener(data);
        } catch (error) {
          console.error(`Error in async listener:`, error);
        }
      })
    );
  }
}

// Uso: aguarda processamento assíncrono
emitter.on('file:uploaded', async (data) => {
  // Processa arquivo (operação async)
  await processFile(data.path);
  await generateThumbnail(data.path);
});

// Aguarda todos os listeners completarem
await emitter.emitAsync('file:uploaded', { 
  path: '/uploads/image.jpg' 
});
console.log('Todos os processamentos concluídos!');

4. Method Chaining

Permite encadear chamadas pra código mais limpo:

class TypedEventEmitter> {
  // Todos os métodos retornam this
  on(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): this {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event]!.push(listener);
    return this; // Retorna a instância
  }

  off(
    event: K,
    listener: (data: TEventMap[K]) => void
  ): this {
    if (!this.events[event]) return this;
    
    const index = this.events[event]!.indexOf(listener);
    if (index > -1) {
      this.events[event]!.splice(index, 1);
    }
    return this;
  }

  emit(
    event: K,
    data: TEventMap[K]
  ): this {
    if (!this.events[event]) return this;
    
    this.events[event]!.forEach(listener => {
      try {
        listener(data);
      } catch (error) {
        console.error(`Error in listener:`, error);
      }
    });
    return this;
  }
}

// Uso: encadeamento fluente
emitter
  .on('user:login', (data) => console.log('Login:', data))
  .on('user:logout', (data) => console.log('Logout:', data))
  .emit('user:login', { id: 1, email: 'test@test.com', name: 'Test' })
  .emit('user:logout', { id: 1 });

Esses 4 patterns cobrem 90% dos casos avançados. Você pode combinar todos numa única classe e ter um event emitter profissional, production-ready, type-safe.

Caso Real: Sistema de Notificações

Vou mostrar um exemplo completo de sistema de notificações. É o tipo de código que você vê em apps reais - múltiplos componentes reagindo a eventos, estado compartilhado, lógica assíncrona.

// 1. Define os eventos do sistema de notificações
type NotificationEvents = {
  'notification:created': {
    id: string;
    userId: number;
    title: string;
    message: string;
    type: 'info' | 'success' | 'warning' | 'error';
    createdAt: Date;
  };
  'notification:read': {
    id: string;
    userId: number;
    readAt: Date;
  };
  'notification:deleted': {
    id: string;
    userId: number;
  };
  'notification:batch-read': {
    userId: number;
    notificationIds: string[];
  };
};

// 2. Cria o emitter tipado
const notificationEmitter = new TypedEventEmitter();

// 3. Sistema de persistência escuta eventos
class NotificationRepository {
  constructor() {
    // Salva notificações criadas no banco
    notificationEmitter.on('notification:created', async (data) => {
      await db.notifications.create({
        id: data.id,
        userId: data.userId,
        title: data.title,
        message: data.message,
        type: data.type,
        createdAt: data.createdAt,
        isRead: false
      });
      console.log(`Notificação ${data.id} salva no banco`);
    });

    // Marca como lida
    notificationEmitter.on('notification:read', async (data) => {
      await db.notifications.update(data.id, {
        isRead: true,
        readAt: data.readAt
      });
    });

    // Remove do banco
    notificationEmitter.on('notification:deleted', async (data) => {
      await db.notifications.delete(data.id);
    });
  }
}

// 4. Sistema de WebSocket envia pra clientes conectados
class NotificationWebSocket {
  private connections = new Map();

  constructor() {
    // Envia notificações novas via WebSocket
    notificationEmitter.on('notification:created', (data) => {
      const userSocket = this.connections.get(data.userId);
      if (userSocket && userSocket.readyState === WebSocket.OPEN) {
        userSocket.send(JSON.stringify({
          type: 'notification:new',
          payload: data
        }));
      }
    });

    // Notifica cliente quando notificação é lida
    notificationEmitter.on('notification:read', (data) => {
      const userSocket = this.connections.get(data.userId);
      if (userSocket && userSocket.readyState === WebSocket.OPEN) {
        userSocket.send(JSON.stringify({
          type: 'notification:read',
          payload: { id: data.id }
        }));
      }
    });
  }

  addConnection(userId: number, socket: WebSocket): void {
    this.connections.set(userId, socket);
  }
}

// 5. Sistema de analytics rastreia tudo
class NotificationAnalytics {
  constructor() {
    // Usa wildcard pra capturar todos os eventos
    notificationEmitter.onAny((event, data) => {
      // Envia pra serviço de analytics
      analytics.track(event, {
        ...data,
        timestamp: new Date().toISOString()
      });
    });
  }
}

// 6. API pública que emite eventos
class NotificationService {
  async createNotification(
    userId: number,
    title: string,
    message: string,
    type: 'info' | 'success' | 'warning' | 'error'
  ): Promise {
    const id = crypto.randomUUID();
    
    // Emite evento - todos os listeners reagem
    await notificationEmitter.emitAsync('notification:created', {
      id,
      userId,
      title,
      message,
      type,
      createdAt: new Date()
    });
    
    return id;
  }

  async markAsRead(notificationId: string, userId: number): Promise {
    await notificationEmitter.emitAsync('notification:read', {
      id: notificationId,
      userId,
      readAt: new Date()
    });
  }

  async deleteNotification(notificationId: string, userId: number): Promise {
    await notificationEmitter.emitAsync('notification:deleted', {
      id: notificationId,
      userId
    });
  }
}

// 7. Inicialização
const notificationRepo = new NotificationRepository();
const notificationWS = new NotificationWebSocket();
const notificationAnalytics = new NotificationAnalytics();
const notificationService = new NotificationService();

// 8. Uso
await notificationService.createNotification(
  123,
  'Nova mensagem',
  'Você tem uma nova mensagem de João',
  'info'
);
// Automaticamente:
// - Salva no banco (Repository)
// - Envia via WebSocket (WebSocket)
// - Rastreia no analytics (Analytics)

Olha a beleza dessa arquitetura. O NotificationService não sabe nada sobre banco de dados, WebSockets ou analytics. Ele só emite eventos. Cada sistema ouve o que interessa e reage independentemente.

Quer adicionar email notifications? Cria uma classe NotificationEmail, registra listeners, pronto. Não precisa mexer em nenhum código existente. É desacoplamento total com type-safety completo.

E o melhor: tudo isso compila com zero erros de tipo. Se você tentar emitir notification:created sem o campo message, TypeScript reclama. Se você tentar acessar data.inexistente num listener, não compila. Segurança end-to-end.

Quando Usar Event Emitter Tipado

Event emitters são top, mas não são solução universal. Vou ser direto sobre quando usar e quando evitar:

Checklist Final

  • Use quando:
    • Sistema com múltiplos componentes que precisam reagir aos mesmos eventos
    • Lógica de negócio precisa ser desacoplada da camada de apresentação
    • Tem integrações assíncronas (WebSockets, emails, analytics) que não devem bloquear fluxo principal
    • Vários módulos precisam ser notificados sobre mudanças de estado
    • Quer adicionar features (logging, analytics) sem modificar código existente
  • Evite quando:
    • Comunicação é síncrona e direta (use chamadas de função normais)
    • Só tem um consumidor pra cada evento (event emitter é overkill)
    • Precisa de controle fino sobre ordem de execução (eventos são fire-and-forget)
    • Sistema é pequeno e simples (adiciona complexidade desnecessária)
    • Debugging precisa ser trivial (eventos adicionam indireção que pode dificultar debug)
  • Alternativas a considerar:
    • Redux/Zustand para estado global compartilhado (melhor DX para UI)
    • RxJS Observables para streams de dados complexos (mais poder, mais curva de aprendizado)
    • Message queues (RabbitMQ, Kafka) para sistemas distribuídos (mais robusto, mais infraestrutura)
    • Callbacks/Promises diretos para comunicação simples ponto-a-ponto

Regra prática: se você tem mais de 2 consumidores diferentes pra um evento, event emitter provavelmente é a escolha certa. Se tem só 1 consumidor, função direta é mais simples.