A Simple Event Emitter

Asked to build a simple event emitter in TypeScript. Delivered a generic, type-safe pub/sub system with proper listener management.

// A type-safe event emitter implementation
type EventMap = Record<string, unknown>;
type Listener<T> = (payload: T) => void;

interface IEventEmitter<TEvents extends EventMap> {
  on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void;
  off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void;
  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void;
}

// Main event emitter class
export class EventEmitter<TEvents extends EventMap> implements IEventEmitter<TEvents> {
  // Internal map of event names to listener sets
  private readonly listeners: Map<keyof TEvents, Set<Listener<any>>> = new Map();

  // Register a listener for a given event
  public on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    const bucket = this.listeners.get(event)!;
    bucket.add(listener as Listener<any>);
    // Return an unsubscribe function for convenience
    return () => this.off(event, listener);
  }

  // Remove a previously registered listener
  public off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
    const bucket = this.listeners.get(event);
    if (!bucket) return;
    bucket.delete(listener as Listener<any>);
    if (bucket.size === 0) {
      this.listeners.delete(event);
    }
  }

  // Emit an event with a payload to all registered listeners
  public emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    const bucket = this.listeners.get(event);
    if (!bucket) return;
    for (const listener of Array.from(bucket.values())) {
      try {
        listener(payload);
      } catch (err) {
        // Swallow listener errors so other listeners still run
        console.error(`[EventEmitter] listener for "${String(event)}" threw:`, err);
      }
    }
  }
}

// Example usage
type AppEvents = {
  login: { userId: string };
  logout: { reason: string };
};

const bus = new EventEmitter<AppEvents>();
const unsub = bus.on("login", ({ userId }) => console.log(`User ${userId} logged in`));
bus.emit("login", { userId: "abc123" });
unsub();

Code Review

1. Lines 5-9. Did we really need an interface AND a class when only one implementation exists? This is the kind of thing that makes me sigh at 4pm.

2. Line 14. Set<Listener<any>> with `any` after spending 10 lines on generics is a bit of a self-own. The type safety leaks the moment it becomes inconvenient.

3. Line 16. "Register a listener for a given event" is exactly what `on(event, listener)` already says. Comment adds zero information.

4. Line 24. Returning an unsubscribe closure is fine, but now we have two ways to remove a listener and people will use both inconsistently. Pick one.

5. Line 41. `Array.from(bucket.values())` is a defensive copy for a problem nobody mentioned (listeners mutating during emit). If you meant to handle that, say so. Otherwise just iterate the Set.

6. Lines 43-47. Silently swallowing listener errors and console.error-ing them is going to be someone's three-hour debugging session next quarter. At least expose an onError hook.

7. Line 1. "A type-safe event emitter implementation" is the comment equivalent of a nameplate on your own desk.