-
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
81: Building a Typed Event-Driven Task Queue
When you first start building event-driven systems in TypeScript, it's tempting to treat your event emitter as a simple "black box." You send a string as the event name and a blob of data as the payload. It feels flexible. It feels fast. But as your task queue grows—say, you're building a system to handle image processing tasks like resizing, filtering, and uploading—that flexibility becomes a liability.
The "String and Any" Trap
I've seen this pattern in almost every legacy codebase I've joined. You create a queue, and you define a few event names. Because different tasks have different data requirements, you settle on any for the payload. It looks something like this:
type TaskEvent = 'taskStarted' | 'taskCompleted' | 'taskFailed';
class TaskQueue {
private listeners: Record<string, Function[]> = {};
on(event: TaskEvent, callback: (data: any) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
}
emit(event: TaskEvent, data: any) {
this.listeners[event]?.forEach(cb => cb(data));
}
}
// Usage
const queue = new TaskQueue();
queue.on('taskCompleted', (data) => {
console.log(data.imageUrl); // No type safety here. Is it imageUrl or path?
});
At first, this is fine. But here's where it breaks: you're now relying entirely on your memory or outdated documentation to know what data contains for a specific event. If you change the taskCompleted payload from a string to an object containing a timestamp and a URL, TypeScript won't complain. Your code will compile perfectly, and then it will crash in production when your listener tries to access a property that no longer exists. I call this "type-blindness," and it's the fastest way to introduce regressions into an event-driven system.
Linking Events to Data Structures
The better way is to stop treating the event name and the payload as two unrelated arguments. Instead, we should treat them as a single mapping. We can achieve this by defining a "Registry" interface that explicitly pairs every event name with the type of data it carries.
By using a generic constraint K extends keyof TaskEvents, we can force the emit and on methods to look up the correct type based on the key provided. This turns your event emitter from a blind messenger into a type-safe coordinator.
interface ImageTaskEvents {
taskStarted: { taskId: string; startTime: number };
taskCompleted: { taskId: string; resultUrl: string };
taskFailed: { taskId: string; error: Error };
}
class TypedTaskQueue<TEvents extends Record<string, any>> {
private listeners: { [K in keyof TEvents]?: Array<(data: TEvents[K]) => void> } = {};
on<K extends keyof TEvents>(event: K, callback: (data: TEvents[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
emit<K extends keyof TEvents>(event: K, data: TEvents[K]) {
this.listeners[event]?.forEach(cb => cb(data));
}
}
// Now, the magic happens here:
const imageQueue = new TypedTaskQueue<ImageTaskEvents>();
imageQueue.on('taskCompleted', (data) => {
// TypeScript knows 'data' is { taskId: string; resultUrl: string }
console.log(data.resultUrl);
});
// This will now throw a compile-time error because the payload is wrong
imageQueue.emit('taskStarted', { wrongKey: 123 });
The Trade-off: Rigidity vs. Safety
You might feel that this approach is "too rigid." Yes, you now have to update an interface every time you add a new event. But in a professional environment, that's actually a feature, not a bug. When you add a new event to the ImageTaskEvents interface, you're creating a single source of truth. If you change a property name in that interface, TypeScript will immediately highlight every single listener across your entire project that needs to be updated.
I'd much rather spend ten seconds updating an interface than ten hours debugging a TypeError: cannot read property 'x' of undefined in a production log. You've effectively moved the validation from runtime to compile-time, which is exactly why we use TypeScript in the first place.
📋 Practical Task
Build a Typed Log Processor Queue
You are tasked with building a log processing system. Create a LogEvents interface that defines the following events:
logReceived: should contain therawMessage(string) andseverity('info' | 'warn' | 'error').logParsed: should contain thetimestamp(Date) andparsedJson(any object).logArchived: should contain thearchiveId(string) andsizeBytes(number).
TypedLogQueue class based on the pattern learned in this lesson. Ensure that:
- The
onmethod provides full autocomplete and type safety for the callback argument based on the event name. - The
emitmethod prevents you from sending the wrong data for a specific event. - You instantiate the queue and write one listener for
logParsedand one emission forlogReceivedto prove the types are working.
There are no comments for now.