Skip to Content
Course content

103: Typing Event-Based APIs

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

When you're building a system that relies on events—like a game engine, a chat app, or a complex UI state manager—you usually end up with an EventEmitter. The problem is that by default, these are often "stringly typed." You pass in a string for the event name, and you get back... something. Usually an any.

I've spent way too many hours debugging runtime crashes because I thought an event was passing a User object when it was actually passing a userId string. Let's build a small GameEventManager to show you how to lock this down so the compiler catches those mistakes for us.

The "Quick and Dirty" approach that bites you

My first instinct when I'm rushing is usually to just make the event name a string and the payload any. It feels flexible, but it's a trap. Look at this:

class GameEventManager {
  private listeners: Record<string, Function[]> = {};

  on(event: string, callback: (data: any) => void) {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event].push(callback);
  }

  emit(event: string, data: any) {
    this.listeners[event]?.forEach(cb => cb(data));
  }
}

const events = new GameEventManager();
events.on('playerMoved', (data) => {
  // I think data has x and y coordinates... but does it?
  console.log(data.x, data.y); 
});

events.emit('playerMoved', { name: 'Hero' }); // Whoops! I sent a name, not coordinates.

The code above compiles perfectly, but it's broken. I emitted a name, but the listener expected coordinates. In a large project, this is how bugs sneak into production. We need a way to tell TypeScript: "If the event name is 'playerMoved', the data must be this specific shape."

Mapping events to their data shapes

The secret here is to stop using string and start using a map. I'll define a type that acts as the single source of truth for every event in my game and what data it carries.

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

Now, instead of string, we can use keyof GameEvents. This limits the event names to only those three specific strings. But that only solves half the problem; we still need the callback to know which type of data it's receiving based on which key was used.

Linking the event name to the payload

This is where generics come in. I want the on method to capture the specific key being passed and then use that key to look up the type in my GameEvents map. I'll use a generic type K that extends the keys of my map.

class GameEventManager {
  // We use a more complex type for listeners to keep them typed
  private listeners: { [K in keyof GameEvents]?: Array<(data: GameEvents[K]) => void> } = {};

  on<K extends keyof GameEvents>(event: K, callback: (data: GameEvents[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    // We have to cast to 'any' here internally because TS struggles 
    // with indexed access in arrays, but the public API remains safe.
    (this.listeners[event] as any[]).push(callback);
  }

  emit<K extends keyof GameEvents>(event: K, data: GameEvents[K]) {
    this.listeners[event]?.forEach(cb => cb(data));
  }
}

Notice what happened in the on method: callback: (data: GameEvents[K]) => void. By using GameEvents[K], I'm telling TypeScript to look up the value associated with the key K. If K is 'playerMoved', GameEvents[K] becomes { x: number; y: number }.

Seeing the safety in action

Now, let's try the same code as before. The difference is night and day:

const events = new GameEventManager();

events.on('playerMoved', (data) => {
  console.log(data.x); // Autocomplete works! TS knows data has x and y.
});

// This now triggers a compile-time error:
// Argument of type '{ name: string; }' is not assignable to parameter of type '{ x: number; y: number; }'
events.emit('playerMoved', { name: 'Hero' }); 

// This also errors because 'playerJumped' isn't in our GameEvents map
events.emit('playerJumped', { height: 10 }); 

I love this pattern because it creates a "contract." If I need to change the itemPickedUp payload to include a timestamp, I change it in one place (the GameEvents type), and TypeScript immediately flags every single listener and emitter in my entire codebase that needs to be updated.




📋 Practical Task

Build a Typed Notification System

You are building a notification system for a dashboard. Create a NotificationEvents type map that supports three events:

  • 'info': payload should be { message: string }
  • 'error': payload should be { message: string; code: number }
  • 'success': payload should be { message: string; duration: number }
Implement a NotificationManager class with on and emit methods. Ensure that:
  1. The on method provides full autocomplete for the payload based on the event name.
  2. The emit method prevents passing the wrong payload for a specific event.
  3. The emit method prevents using an event name that isn't defined in your map.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.