Skip to Content
Course content

81: Building a Typed Event-Driven Task Queue

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

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 the rawMessage (string) and severity ('info' | 'warn' | 'error').
  • logParsed: should contain the timestamp (Date) and parsedJson (any object).
  • logArchived: should contain the archiveId (string) and sizeBytes (number).
Implement a TypedLogQueue class based on the pattern learned in this lesson. Ensure that:
  1. The on method provides full autocomplete and type safety for the callback argument based on the event name.
  2. The emit method prevents you from sending the wrong data for a specific event.
  3. You instantiate the queue and write one listener for logParsed and one emission for logReceived to prove the types are working.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.