Taming Command Bus Generics with TypeScript Phantom Types
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:
1const result = await commandBus.execute(2 new CreateUserCommand({ name: "Ariel" }),3);4
5// result should resolve to CreateUserResult without specifying generics explicitlyThe supporting types were already available:
1interface Command<Payload, Result> {2 readonly type: string;3 readonly payload: Payload;4}5
6interface CommandHandler<C extends Command<any, any>> {7 handle(command: C): Promise<CommandResult<C>>;8}9
10type 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:
1await 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.
1type Phantom<Result> = { readonly __result?: Result };2
3interface 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.
1class 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
15type 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:
-
Runtime characteristics: The optional property exists only on the interface, so the compiled JavaScript remains unchanged and bundle size is unaffected.
-
Developer experience: Requiring every command to supply the phantom property can be inconvenient. A factory helper keeps the ergonomics consistent:
1const defineCommand =2<Payload, Result>() =>3(type: string, payload: Payload): Command<Payload, Result> => ({4type,5payload,6});78const createUser = defineCommand<CreateUserInput, CreateUserResult>();910const command = createUser("user.create", { name: "Ariel" });The helper ensures the phantom information is present while keeping command construction concise.
Key Takeaways
- TypeScript preserves generic bindings when type information appears on a tangible property; phantom types deliver this link without altering runtime behaviour.
- CQRS command buses depend on accurate command-to-result mapping, and the phantom approach maintains those contracts without additional annotations.
- Developer-facing helpers can restore ergonomics after introducing type-level plumbing.
The phantom type pattern maintained the desired API while satisfying the compiler’s requirements, yielding a predictable and maintainable command bus implementation.