Typed Event Emitter With Listener Limits

A generic event emitter whose event names and payload types are checked at compile time, with a listener cap that prevents unbounded growth.

TypescriptCli Tooling

Code

export type EventMap = Record<string, unknown>;

export class TypedEmitter<TEvents extends EventMap> {
  private readonly listeners = new Map<keyof TEvents, Set<(payload: never) => void>>();
  private readonly maxListeners: number;

  constructor(options: { maxListeners?: number } = {}) {
    this.maxListeners = options.maxListeners ?? 10;
    if (!Number.isInteger(this.maxListeners) || this.maxListeners < 1) {
      throw new RangeError("maxListeners must be a positive integer");
    }
  }

  on<TKey extends keyof TEvents>(
    event: TKey,
    listener: (payload: TEvents[TKey]) => void,
  ): () => void {
    let set = this.listeners.get(event);
    if (!set) {
      set = new Set();
      this.listeners.set(event, set);
    }
    if (set.size >= this.maxListeners) {
      throw new Error(`listener limit ${this.maxListeners} exceeded for event ${String(event)}`);
    }
    const wrapped = listener as (payload: never) => void;
    set.add(wrapped);
    return () => this.off(event, listener);
  }

  off<TKey extends keyof TEvents>(
    event: TKey,
    listener: (payload: TEvents[TKey]) => void,
  ): void {
    const set = this.listeners.get(event);
    if (!set) return;
    set.delete(listener as (payload: never) => void);
    if (set.size === 0) this.listeners.delete(event);
  }

  emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
    const set = this.listeners.get(event);
    if (!set) return;
    // snapshot so removal during dispatch does not skip listeners
    for (const listener of [...set]) listener(payload as never);
  }

  listenerCount(event: keyof TEvents): number {
    return this.listeners.get(event)?.size ?? 0;
  }
}