Right, so you need events. You’ve outgrown passing callbacks around like hot potatoes and you’re ready for something that can handle the chaos of a real application. But you’re also in TypeScript, which means you’re not about to trade type safety for convenience. You want to know, at compile time, if you’re trying to emit 'userLoggedIn' with a string payload when your listener expects a User object. We’re not savages.

The naive way is to just reach for EventEmitter from Node.js or the browser. But let’s be honest, its type safety is about as robust as a paper umbrella in a hurricane. It’s basically EventEmitter<any>, and any is our sworn enemy. We’re going to build something better.

The Core Concept: A Type Map

The trick is to define the contract for your events upfront. We use a simple interface where each key is the event name and the value is the type of its payload. A payload can be a single type, a tuple for multiple arguments, or void/undefined for events that just signal something happened.

interface MyAppEvents {
  // An event with a single string payload
  'notification': string;
  // An event with multiple arguments (a tuple)
  'dataReceived': [userId: number, data: object];
  // An event with no payload whatsoever
  'ready': void;
}

This MyAppEvents interface is our single source of truth. It’s the contract between the part of your code that emits events and the part that listens to them. Now, let’s build the emitter that enforces it.

Building a Generic Event Emitter

We’ll create a class that is generic on our event map. Its methods (on, emit, etc.) will use the keys and values from that map to enforce types.

class TypedEventEmitter<T extends Record<string, any>> {
  // Our internal store for listeners.
  // A map of event names to an array of functions listening to that event.
  private listeners: {
    [K in keyof T]?: Array<(payload: T[K]) => void>;
  } = {};

  // Subscribe to an event
  on<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void {
    // Initialize the array for this event if it doesn't exist
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    // Push the new listener onto the array
    this.listeners[eventName]!.push(listener);
  }

  // Emit an event, notifying all subscribers
  emit<K extends keyof T>(eventName: K, payload: T[K]): void {
    // If no one is listening, just return early. It's less depressing.
    const eventListeners = this.listeners[eventName];
    if (!eventListeners) return;

    // Call every listener with the payload.
    // We slice() to create a copy so if a listener unsubscribes mid-emit, we don't break.
    for (const listener of eventListeners.slice()) {
      listener(payload);
    }
  }

  // Unsubscribe a specific listener
  off<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void {
    const eventListeners = this.listeners[eventName];
    if (!eventListeners) return;

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

Now, watch the magic happen when we use it with our MyAppEvents:

const emitter = new TypedEventEmitter<MyAppEvents>();

// This works perfectly
emitter.on('notification', (message) => {
  console.log(message.toUpperCase()); // TS knows `message` is a string
});

emitter.emit('notification', 'New email!'); // ✅

// This causes a compile-time error. Beautiful.
emitter.emit('notification', 42); // ❌ Argument of type 'number' is not assignable to type 'string'.

// This also works for our multi-argument event
emitter.on('dataReceived', ([userId, data]) => {
  console.log(`Data for user ${userId}:`, data); // userId is number, data is object
});

emitter.emit('dataReceived', [123, { unread: true }]); // ✅

See? The compiler becomes your very annoyed, hyper-competent code reviewer, preventing a whole class of runtime bugs.

Handling the “No Payload” Scenario

You might wonder how the 'ready': void event works. It’s elegantly simple. A function type (payload: void) => void can be called with an argument, but that argument must be undefined (which is what void effectively is in this context). So you can emit it with no argument, and TypeScript is perfectly happy.

emitter.on('ready', () => {
  console.log('App is ready!');
});

// Both of these are valid
emitter.emit('ready'); // ✅ This is the clean way. The `payload` parameter is `undefined`.
emitter.emit('ready', undefined); // ✅ This also works, but it's uglier.

The Once-Only Listener Pattern

A common need is a listener that should only fire one time. You could do this manually with on and then immediately calling off, but that’s clunky. Let’s add a helper method. This is where we start to see the real power of wrapping this logic ourselves.

class TypedEventEmitter<T extends Record<string, any>> {
  // ... previous code ...

  once<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void {
    // Create a self-removing wrapper function
    const onceWrapper = (payload: T[K]) => {
      // First, remove itself from the listeners so it can't be called again
      this.off(eventName, onceWrapper);
      // Then, call the original listener with the payload
      listener(payload);
    };
    // Subscribe the wrapper instead of the original listener
    this.on(eventName, onceWrapper);
  }
}

Now you can do:

emitter.once('ready', () => {
  console.log('App was ready. This will only log once.');
});

emitter.emit('ready');
emitter.emit('ready'); // Nothing happens the second time.

Pitfalls and Sharp Edges

  1. The any Trap in Listeners: Notice our listener map uses any in the generic constraint? It’s a necessary evil to make the interface flexible. Just remember: the type safety comes from the public on and emit methods, not from the internal storage. Don’t muck about with the listeners object directly.
  2. Memory Leaks: This is the big one. If you subscribe objects as listeners (e.g., this.someMethod.bind(this)), you must remember to call off() when that object is being destroyed. Otherwise, the emitter holds a reference, preventing garbage collection. This is why frameworks like React have useEffect cleanup functions. It’s your job to manage your subscriptions.
  3. Error Handling: What if a listener throws an error? In our simple emit loop, it will break the loop and prevent subsequent listeners from being called. You might want to wrap the listener(payload) call in a try/catch block to make the emitter more robust, but then you have to decide how to handle those errors—another event? A global handler? It’s a design choice.