Skip to content

┌─< lukebayliss.com >─┐

/snippets/type-safe-event-emitter

Type-Safe Event Emitter

A strongly-typed event emitter pattern for TypeScript that provides autocomplete and type checking for event names and payloads.

  • patterns
  • type-safety
  • events

This event emitter provides full type safety for event names and their associated payloads, catching errors at compile time instead of runtime.

type EventMap = Record<string, any>;

type EventKey<T extends EventMap> = string & keyof T;
type EventCallback<T> = (params: T) => void;

class TypedEventEmitter<T extends EventMap> {
  private events: {
    [K in keyof T]?: EventCallback<T[K]>[];
  } = {};

  on<K extends EventKey<T>>(eventName: K, callback: EventCallback<T[K]>): () => void {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }

    this.events[eventName]!.push(callback);

    // Return unsubscribe function
    return () => {
      this.off(eventName, callback);
    };
  }

  off<K extends EventKey<T>>(eventName: K, callback: EventCallback<T[K]>): void {
    const callbacks = this.events[eventName];
    if (!callbacks) return;

    const index = callbacks.indexOf(callback);
    if (index > -1) {
      callbacks.splice(index, 1);
    }
  }

  emit<K extends EventKey<T>>(eventName: K, params: T[K]): void {
    const callbacks = this.events[eventName];
    if (!callbacks) return;

    callbacks.forEach(callback => {
      try {
        callback(params);
      } catch (error) {
        console.error(`Error in event listener for "${String(eventName)}":`, error);
      }
    });
  }

  once<K extends EventKey<T>>(eventName: K, callback: EventCallback<T[K]>): () => void {
    const onceCallback: EventCallback<T[K]> = (params) => {
      this.off(eventName, onceCallback);
      callback(params);
    };

    return this.on(eventName, onceCallback);
  }

  removeAllListeners<K extends EventKey<T>>(eventName?: K): void {
    if (eventName) {
      delete this.events[eventName];
    } else {
      this.events = {};
    }
  }
}

// Usage with typed events:

interface AppEvents {
  'user:login': { userId: string; timestamp: Date };
  'user:logout': { userId: string };
  'data:updated': { entityId: string; changes: Record<string, any> };
  'error': { message: string; code: number };
}

const emitter = new TypedEventEmitter<AppEvents>();

// Type-safe subscription with autocomplete
const unsubscribe = emitter.on('user:login', (data) => {
  // TypeScript knows data has { userId: string; timestamp: Date }
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

// Type-safe emission
emitter.emit('user:login', {
  userId: '123',
  timestamp: new Date()
});

// TypeScript error: wrong payload type
// emitter.emit('user:login', { userId: 123 }); // Error: number not assignable to string

// TypeScript error: unknown event
// emitter.on('unknown:event', () => {}); // Error: "unknown:event" not in AppEvents

// Once listener - executes only on first occurrence
emitter.once('error', (error) => {
  console.error(`Error occurred: ${error.message}`);
});

// Cleanup
unsubscribe(); // Removes specific listener
emitter.removeAllListeners('user:login'); // Removes all listeners for event
emitter.removeAllListeners(); // Removes all listeners for all events

// Real-world example: WebSocket wrapper
interface WebSocketEvents {
  'open': { timestamp: Date };
  'message': { data: unknown };
  'close': { code: number; reason: string };
  'error': { error: Error };
}

class TypedWebSocket extends TypedEventEmitter<WebSocketEvents> {
  private ws: WebSocket;

  constructor(url: string) {
    super();
    this.ws = new WebSocket(url);

    this.ws.onopen = () => {
      this.emit('open', { timestamp: new Date() });
    };

    this.ws.onmessage = (event) => {
      this.emit('message', { data: JSON.parse(event.data) });
    };

    this.ws.onclose = (event) => {
      this.emit('close', { code: event.code, reason: event.reason });
    };

    this.ws.onerror = () => {
      this.emit('error', { error: new Error('WebSocket error') });
    };
  }

  send(data: unknown): void {
    this.ws.send(JSON.stringify(data));
  }

  close(): void {
    this.ws.close();
  }
}

// Usage
const socket = new TypedWebSocket('wss://example.com');

socket.on('message', ({ data }) => {
  // Fully typed data handling
  console.log('Received:', data);
});

socket.on('error', ({ error }) => {
  console.error('Socket error:', error);
});

Benefits

  • Autocomplete: IDEs suggest available event names
  • Type Safety: Payloads are validated at compile time
  • Refactoring: Renaming events updates all usages
  • Documentation: Event types serve as inline documentation
  • Error Prevention: Typos in event names are caught immediately