TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
103: Typing Event-Based APIs
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 }
NotificationManager class with on and emit methods. Ensure that:
- The
onmethod provides full autocomplete for the payload based on the event name. - The
emitmethod prevents passing the wrong payload for a specific event. - The
emitmethod prevents using an event name that isn't defined in your map.
There are no comments for now.