Skip to Content
Course content

60: Building a Type-Safe Event Emitter

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

Event emitters are everywhere in the JS ecosystem, but most of the ones you'll find in the wild are "type-blind." They usually treat event names as strings and payloads as any. While that works, it's a nightmare for maintenance; you end up having to cast types manually in every listener, which completely defeats the purpose of using TypeScript.

Let's build a type-safe event system for a simple game. We want to handle events like a player moving or an item being picked up, and we want the compiler to scream at us if we try to pass a string to an event that expects a coordinate object.

Defining the Event Map

The secret to type safety here isn't in the Emitter class itself, but in how we define the relationship between the event name and the data it carries. Instead of letting any string be an event, we'll use an interface as a "source of truth."

type GameEvents = {
  playerMoved: { x: number; y: number };
  itemPickedUp: { itemId: string; rarity: 'common' | 'rare' | 'legendary' };
  gameOver: { score: number };
};

By defining this map, we've created a contract. If the event is 'playerMoved', the payload must be an object with x and y coordinates. No exceptions.

Implementing the Generic Emitter

Now we need a class that can use this map. I'll use a generic T that extends our map structure. This allows the Emitter to be reused for different systems (UI events, Network events, etc.) while remaining strictly typed.

class EventEmitter<T extends Record<string, any>> {
  private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};

  on<K extends keyof T>(event: K, callback: (payload: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends keyof T>(event: K, payload: T[K]) {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      eventListeners.forEach(callback => callback(payload));
    }
  }
}

The "Any" Leak and How I Fixed It

When I first wrote this, I actually tried to define the listeners object as a simple Record<string, Function[]>. It felt easier. But I quickly realized that inside the emit method, TypeScript had no idea which specific function was being called. I was forced to use (callback as Function)(payload) to make it compile.

That's a red flag. Whenever you find yourself casting to Function or any just to satisfy the compiler in a generic class, you're usually fighting the type system rather than using it. I fixed this by using a Mapped Type for the listeners property: { [K in keyof T]?: Array<(payload: T[K]) => void> }. This tells TypeScript: "For every key in my event map, the corresponding listener list must accept the specific payload type associated with that key."

Wiring it All Together

Now, let's see it in action. Because we passed GameEvents into the generic, the IDE will now provide autocomplete for the event names and the exact shapes of the payloads.

const gameBus = new EventEmitter<GameEvents>();

// TypeScript knows 'payload' is { x: number, y: number }
gameBus.on('playerMoved', (payload) => {
  console.log(`Player moved to ${payload.x}, ${payload.y}`);
});

// This would cause a compile-time error because 'score' is missing
// gameBus.emit('gameOver', { reason: 'fell off map' }); 

gameBus.emit('gameOver', { score: 1500 });

It's a small amount of overhead in the class definition, but it removes an entire category of runtime bugs where you're trying to access a property on a payload that doesn't exist.




📋 Practical Task

Build a Type-Safe Notification Dispatcher

Create a type-safe Event Emitter system for a notification service. Your solution must include:

  • An event map called NotificationEvents with at least three events: emailSent (payload: { email: string, subject: string }), smsFailed (payload: { phone: string, errorCode: number }), and pushNotificationDelivered (payload: { deviceId: string }).
  • A generic NotificationEmitter class that implements on and emit methods.
  • The listeners storage within the class must be strictly typed using a mapped type—avoid using any or Function for the listener arrays.
  • A demonstration where you subscribe to emailSent and emit a valid smsFailed event.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.