Skip to Content
Course content

173: Practice Exercise: Building a Typed Command Bus

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

A few years ago, I was reviewing a pull request for a colleague who was building a complex order processing system. They had implemented a "Command Bus" to decouple the API controllers from the business logic. It looked clean at first glance, but the core method was essentially execute(command: any): Promise<any>. I remember the dread I felt when I realized that if someone renamed a property in the ProcessPaymentCommand, the compiler wouldn't say a word. The system would just crash at runtime when the handler tried to access a field that didn't exist. It was a classic case of using TypeScript as a suggestion rather than a guarantee.

The goal of a typed command bus is to create a strict contract between a command (the "what") and its handler (the "how"). We want the TypeScript compiler to know that if you pass a CreateUserCommand into the bus, the bus is internally routing it to a handler that specifically expects that command and returns a specific result—like a UserId—without us having to cast types manually every time we call the bus.

Linking Commands to their Results

To make this work, we can't just use a generic Command interface. We need a way to map the command type to its expected return type. I usually do this by defining a registry—a type map—that acts as the single source of truth for the entire application. Instead of the bus guessing what happens, we tell it explicitly: "When this command is sent, this is the type that comes back."

type CommandRegistry = {
  CreateUserCommand: { response: UserId; payload: { name: string; email: string } };
  DeleteUserCommand: { response: void; payload: { id: UserId } };
};

By structuring our registry this way, we can use indexed access types to extract the response and payload. This removes the need for any or unknown and lets us leverage the power of TypeScript's type system to enforce consistency across our handler implementations.

Implementing the Type-Safe Dispatcher

The real magic happens in the dispatch method. You want a method that accepts a command key and its associated payload, then returns the specific response type mapped in your registry. The trick is using a generic parameter that extends the keys of your registry. If you try to pass a command key that doesn't exist in the map, or a payload that doesn't match the definition for that specific key, the compiler will stop you immediately.

I've found that the cleanest way to implement the internal handler lookup is to use a Map where the keys are the command identifiers and the values are the handler instances. However, because the handlers have different input and output types, you'll likely have to use a small amount of type assertion inside the dispatch method itself. This is a professional trade-off: we use a narrow "unsafe" area inside the bus implementation to provide a 100% type-safe public API for the rest of the team. It's much better to have one as any inside a utility class than a thousand as any calls scattered throughout your business logic.




📋 Practical Task

Exercise: Implementing a Type-Safe Inventory Command Bus

Your task is to build a typed Command Bus for a warehouse inventory system. You need to ensure that the dispatch method is fully type-safe based on a provided registry.

Requirements:

  • Define a CommandRegistry type that maps AddItemCommand (returns itemId: string) and RemoveItemCommand (returns success: boolean).
  • Create a CommandHandler interface that takes a payload and returns a response.
  • Implement a CommandBus class with a dispatch<K extends keyof CommandRegistry> method.
  • The dispatch method must take the command key and the correct payload for that key, and return the correct response type defined in the registry.
  • Implement a simple mock handler for AddItemCommand and register it within the bus.

Test your implementation: Try to call bus.dispatch('AddItemCommand', { wrongField: 123 }). If the TypeScript compiler doesn't throw an error, your mapping isn't strict enough!

Rating
0 0

There are no comments for now.

to be the first to leave a comment.