Abelfubu logo

Taming Command Bus Generics with TypeScript Phantom Types

Abel de la Fuente
Abel de la Fuente 11 min read -
Taming Command Bus Generics with TypeScript Phantom Types
Photo by Eleanor Brooke on Unsplash

Revisiting the Command Bus

Command Query Responsibility Segregation (CQRS) provides a reliable structure for complex applications, yet it exposes practical challenges when combined with TypeScript’s type system. During a recent refinement of a command bus, each command was designed to return a dedicated payload and the bus was expected to infer the result type automatically. The compiler did not comply.

Desired API

The intended usage was straightforward:

1
const result = await commandBus.execute(
2
new CreateUserCommand({ name: "Ariel" }),
3
);
4
5
// result should resolve to CreateUserResult without specifying generics explicitly

The supporting types were already available:

1
interface Command<Payload, Result> {
2
readonly type: string;
3
readonly payload: Payload;
4
}
5
6
interface CommandHandler<C extends Command<any, any>> {
7
handle(command: C): Promise<CommandResult<C>>;
8
}
9
10
type CommandResult<C extends Command<any, any>> =
11
C extends Command<any, infer R> ? R : never;

The missing capability was for commandBus.execute() to recognise that submitting a CreateUserCommand must yield a CreateUserResult.

Where Inference Failed

Multiple iterations of type constraints, infer clauses, and method overloads still produced Promise<unknown> unless the generic parameter was provided explicitly:

1
await commandBus.execute<CreateUserCommand>(/* ... */);

Handlers were stored in a registry as values, which caused TypeScript to sever the relationship between command and result. From the compiler’s perspective, the method accepted Command<any, any> and offered no further insight.

Applying a Phantom Type

The solution was to leave a value-level hint that survived inference. A phantom type adds a property that conveys type information while remaining inert at runtime.

1
type Phantom<Result> = { readonly __result?: Result };
2
3
interface Command<Payload, Result> {
4
readonly type: string;
5
readonly payload: Payload;
6
readonly phantom?: Phantom<Result>;
7
}

By introducing the optional phantom field, the compiler gains a concrete property carrying Result. The property is never populated, yet it preserves the generic binding through to the handler.

1
class CommandBus {
2
constructor(private readonly handlers: Map<string, CommandHandler<any>>) {}
3
4
execute<C extends Command<any, any>>(command: C): CommandResultPromise<C> {
5
const handler = this.handlers.get(command.type);
6
7
if (!handler) {
8
throw new Error(`No handler for ${command.type}`);
9
}
10
11
return handler.handle(command);
12
}
13
}
14
15
type CommandResultPromise<C extends Command<any, any>> =
16
C extends Command<any, infer R> ? Promise<R> : never;

With the phantom type in place, TypeScript infers Promise<CreateUserResult> without additional annotations.

Runtime and Ergonomics

Two practical considerations remained:

  1. Runtime characteristics: The optional property exists only on the interface, so the compiled JavaScript remains unchanged and bundle size is unaffected.

  2. Developer experience: Requiring every command to supply the phantom property can be inconvenient. A factory helper keeps the ergonomics consistent:

    1
    const defineCommand =
    2
    <Payload, Result>() =>
    3
    (type: string, payload: Payload): Command<Payload, Result> => ({
    4
    type,
    5
    payload,
    6
    });
    7
    8
    const createUser = defineCommand<CreateUserInput, CreateUserResult>();
    9
    10
    const command = createUser("user.create", { name: "Ariel" });

    The helper ensures the phantom information is present while keeping command construction concise.

Key Takeaways

The phantom type pattern maintained the desired API while satisfying the compiler’s requirements, yielding a predictable and maintainable command bus implementation.