Skip to Content
Course content

238: Typing an Event Bus with Overloaded Emit

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you're building a decoupled system, an event bus is a lifesaver. But if you've tried building one in TypeScript, you've probably run into the "any" trap. You want a single emit method that can handle ten different event types, each with a different payload. If you just type the payload as any, you've basically turned off TypeScript, which defeats the whole purpose of using it.

Why can't I just use a generic record for the event payloads?

You technically can, but it gets messy fast. If you use a generic emit<T>(event: string, data: T), the compiler doesn't actually know which T belongs to which event string. You'd have to manually pass the type every time you call the function, like bus.emit<PlayerPayload>('PLAYER_MOVE', data). That's tedious and error-prone.

Overloads allow us to create a strict mapping. We tell TypeScript: "If the first argument is 'USER_LOGIN', the second argument must be a User object." This gives you that sweet, automatic autocomplete in your IDE without you having to explicitly pass generic types at the call site.

How do I actually structure the emit overloads?

The trick is to list your specific signatures first, and then provide one general "implementation signature" that handles the logic. Let's look at a game state example. I want a bus that handles player movement and game-over events.

interface GameEvents {
  'PLAYER_MOVE': { x: number; y: number };
  'PLAYER_LEVEL_UP': { level: number; xp: number };
  'GAME_OVER': { winner: string };
}

class EventBus {
  // Overload 1: Player Move
  emit(event: 'PLAYER_MOVE', data: GameEvents['PLAYER_MOVE']): void;
  // Overload 2: Level Up
  emit(event: 'PLAYER_LEVEL_UP', data: GameEvents['PLAYER_LEVEL_UP']): void;
  // Overload 3: Game Over
  emit(event: 'GAME_OVER', data: GameEvents['GAME_OVER']): void;

  // The implementation signature (this is where the actual code lives)
  emit(event: string, data: any): void {
    console.log(`Emitting ${event} with data:`, data);
    // Logic to notify listeners goes here
  }
}

const bus = new EventBus();
bus.emit('PLAYER_MOVE', { x: 10, y: 20 }); // Works!
bus.emit('PLAYER_MOVE', { level: 5 });    // Error: Type '{ level: number }' is not assignable to...

Why does the implementation signature use 'any'?

This is where a lot of people get tripped up. In TypeScript, the "implementation signature" (the last one with the actual function body) is not visible to the outside world. It's only used to ensure that the function body can handle all the overloads defined above it.

Since your emit method needs to be flexible enough to accept a User, a Score, or a Boolean, the implementation signature has to be broad. Using any or unknown here is perfectly fine—and actually necessary—because the strict type checking happens at the overload level before the code ever hits the function body.

Can I avoid writing out every single overload by hand?

If you have 50 events, writing 50 overloads is a nightmare. I've been there. While function overloads are great for a small, fixed set of events, if your event list is huge, you should shift to using a generic constraint combined with a lookup interface.

Instead of multiple emit lines, you can do this:

class EventBus {
  emit<K extends keyof GameEvents>(event: K, data: GameEvents[K]): void {
    console.log(`Emitting ${event}`, data);
  }
}

This achieves the exact same type safety as overloads but in one line. I usually recommend overloads when the logic for different events might diverge significantly, but for a standard "pass this data to that listener" bus, the generic lookup is the cleaner way to go.




📋 Practical Task

Exercise: Type-Safe Analytics Event Bus

You are building an analytics module for an e-commerce site. You need to implement an AnalyticsBus that ensures tracking events are sent with the correct metadata.

Requirements:

  • Define an interface AnalyticsEvents with three events:
    • 'ADD_TO_CART': payload should be { productId: string, price: number }
    • 'PURCHASE': payload should be { transactionId: string, total: number }
    • 'PAGE_VIEW': payload should be { url: string, referrer: string }
  • Create an AnalyticsBus class with an emit method.
  • Use function overloads to ensure that calling emit('ADD_TO_CART', { ... }) only accepts the product ID and price.
  • Implement the emit method body to simply log the event and data to the console.
  • Test your implementation by attempting to emit a 'PURCHASE' event with PAGE_VIEW data to confirm TypeScript throws a compile-time error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.