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
60: Building a Type-Safe Event Emitter
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
NotificationEventswith at least three events:emailSent(payload:{ email: string, subject: string }),smsFailed(payload:{ phone: string, errorCode: number }), andpushNotificationDelivered(payload:{ deviceId: string }). - A generic
NotificationEmitterclass that implementsonandemitmethods. - The
listenersstorage within the class must be strictly typed using a mapped type—avoid usinganyorFunctionfor the listener arrays. - A demonstration where you subscribe to
emailSentand emit a validsmsFailedevent.
There are no comments for now.