'R' could be instantiated with an arbitrary type which could be unrelated to 'Response<Command>'
Asked Answered
E

2

65

Ok, so I'm trying to implement a simple "Command Bus" in TypeScript, but I'm tripping up over generics and I wonder if someone could help me. Here is my code:

This is the interface for the commandbus

export default interface CommandBus {
  execute: <C extends Command, R extends Response<C>>(command: C) => Promise<R>;
}

This is the implementation

export default class AppCommandBus implements CommandBus {
  private readonly handlers: Handler<Command, Response<Command>>[];

  /* ... constructor ... */

  public async execute<C extends Command, R extends Response<C>>(
    command: C
  ): Promise<R> {
    const resolvedHandler = this.handlers.find(handler =>
      handler.canHandle(command)
    );

    /* ... check if undef and throw ... */

    return resolvedHandler.handle(command);
  }
}

and this is what the Handler interface looks like:

export default interface Handler<C extends Command, R extends Response<C>> {
  canHandle: (command: C) => boolean;
  handle: (command: C) => Promise<R>;
}

Command is (currently) an empty interface, and Response looks like this:

export default interface Response<C extends Command> {
  command: C;
}

I'm getting the follow compile error error against the last line of the execute function of the commandbus and I'm completely stumped.

type 'Response<Command>' is not assignable to type 'R'. 'R' could be instantiated with an arbitrary type which could be unrelated to 'Response<Command>'.

If anyone is able to help me understand what I'm doing wrong, I'd be eternally grateful!

EDIT

I've realised I can work around this with a typecast:

const resolvedHandler = (this.handlers.find(handler =>
  handler.canHandle(command)
) as unknown) as Handler<C, R> | undefined;

But I'd still like to know how to resolve this double cast.

Extrados answered 28/6, 2020 at 14:6 Comment(0)
A
74

Generic functions in TypeScript act as a function representing every possible specification of its generic type parameters, since it's the caller of the function that specifies the type parameter, not the implementer:

type GenericFunction = <T>(x: T) => T;

const cantDoThis: GenericFunction = (x: string) => x.toUpperCase(); // error! 
// doesn't work for every T
cantDoThis({a: "oops"}); // caller chooses {a: string}: runtime error

const mustDoThis: GenericFunction = x => x; // okay, verifiably works for every T
mustDoThis({a: "okay"}); // okay, caller chooses {a: string}

So, let's look at CommandBus:

interface CommandBus {
  execute: <C extends Command, R extends Response<C>>(command: C) => Promise<R>;
}

The execute() method of CommandBus is a generic function that claims to be able to accept a command of any subtype of Command the caller wants (okay so far probably), and return a value of Promise<R>, where R is any subtype of Response<C> that the caller wants. That doesn't seem to be something anyone could plausibly implement, and presumably you'll always have to assert that the response you're returning is the R the caller asked for. I doubt this is your intent. Instead, how about something like this:

interface CommandBus {
    execute: <C extends Command>(command: C) => Promise<Response<C>>;
}

Here, execute() only has one generic parameter, C, corresponding to the type of the passed-in command. And the return value is just Promise<Response<C>>, not some subtype that the caller is asking for. This is more plausibly implementable, as long as you have some way of guaranteeing that you have an appropriate handler for every C (say, by throwing if you don't.)


That leads us to your Handler interface:

interface Handler<C extends Command, R extends Response<C>> {
  canHandle: (command: C) => boolean;
  handle: (command: C) => Promise<R>;
}

Even if we release ourselves from the tyranny of trying to represent the particular subtype of Response<C> a handler will produce, like this:

interface Handler<C extends Command> {
  canHandle: (command: C) => boolean;
  handle: (command: C) => Promise<Response<C>>;
}

we still have a problem with canHandle(). And that's the fact that Handler is itself a generic type. The difference between generic functions and generic types has to do with who gets to specify the type parameter. For functions, it's the caller. For types, it's the implementer:

type GenericType<T> = (x: T) => T;

const cantDoThis: GenericType = (x: string) => x.toUpperCase(); // error! no such type

const mustDoThis: GenericType<string> = x => x.toUpperCase(); // okay, T is specified
mustDoThis({ a: "oops" }); // error! doesn't accept `{a: string}`
mustDoThis("okay");

You want Handler<C> to only handle() a command of type C, which is fine. But its canHandle() method also demands that the command be of type C, which is too strict. You want the caller of canHandle() to choose the C, and have the return value be true or false depending on whetehr the C chosen by the caller matches the one chosen by the implementer. To represent this in the type system, I'd suggest making canHandle() a generic user-defined type guard method of a parent interface that isn't generic at all, like this:

interface SomeHandler {
    canHandle: <C extends Command>(command: C) => this is Handler<C>;
}

interface Handler<C extends Command> extends SomeHandler {
    handle: (command: C) => Promise<Response<C>>;
}

So, if you have a SomeHandler, all you can do is call canHandle(). If you pass it a command of type C, and canHandle() returns true, the compiler will understand that your handler is a Handler<C> and you can call it. Like this:

function testHandler<C extends Command>(handler: SomeHandler, command: C) {
    handler.handle(command); // error!  no handle method known yet
    if (handler.canHandle(command)) {
        handler.handle(command); // okay!
    }
}

We're almost done. The only fiddly bit is that you're using SomeHandler[]'s find() method to locate one appropriate for command. The compiler cannot peer into the callback handler => handler.canHandle(command) and deduce that the callback is of type (handler: SomeHandler) => handler is SomeHandler<C>, so we have to help it out by annotating it as such. Then the compiler will understand that the return value of find() is Handler<C> | undefined:

class AppCommandBus implements CommandBus {
    private readonly handlers: SomeHandler[] = [];

    public async execute<C extends Command>(
        command: C
    ): Promise<Response<C>> {
        const resolvedHandler = this.handlers.find((handler): handler is Handler<C> =>
            handler.canHandle(command)
        );
        if (!resolvedHandler) throw new Error();
        return resolvedHandler.handle(command);
    }
}

This works pretty well with the type system and is as nice as I can make it. It may or may not work for your actual use case, but hopefully it gives you some ideas about how generics can be used effectively. Good luck!

Playground link to code

Adjudge answered 28/6, 2020 at 17:20 Comment(6)
Regarding the first point: The problem here is related to the fact that its a return value, the actually instantiation of which is controlled by the implementation. If the caller specified bus.execute<SomeCommand, SomeResponse>(), it has no way of ensuring that the implementation doesn't in fact return SomeOtherResponse, which would both be legal based on my type signature.Extrados
So for example, SomeResponse might contain properties that don't exist on SomeOtherResponse, which would kind of break type safety.Extrados
You talked about my what my "intent" was. To clarify, my intent was this: I wanted a consumer to be able to do something like const response = bus.execute(command); and have the compiler correctly infer the concrete type of the response from the type of the command passed in. Is this possible?Extrados
To illustrate: gist.github.com/benwainwright/a1ec2410d0643f069a7e49b61e9f711cExtrados
I had to add that any typecast to get this test to work after making your changes.Extrados
After spending some time to understand, I finally understand what this TS error is saying. Just like how Java generics are handledAurify
A
0

@jcalz answer has it down pat with the explanation that a particular response must be correlated with a particular command, and that canHandle and handle must be referring to the same particular C, for which the solution

interface SomeHandler {
    canHandle: <C extends Command>(command: C) => this is Handler<C>;
}
interface Handler<C extends Command> extends SomeHandler {
    handle: (command: C) => Promise<Response<C>>;
}

was offered, which is pretty cool.

Maybe I could add something by pointing out that in

interface Handler<C extends Command, R extends Response<C>> {...}
                  ~ C1                                  ~ C2                    

each occurrence of the same symbol C refers to separate choice from Command.

Another way to express the type correlations is with an explicit object type or tuple, as those have the advantage the TypeScript is then able to correlate keys and values unambiguously.

Here's one way to do that:

type CommandTable = {
    "open": number,
    "query": string,
    "close": undefined
}
export type Command = keyof CommandTable;
export type Response<C extends Command> = CommandTable[C];
export interface CommandBus {
  execute: <C extends Command>(command: C) => Promise<Response<C>>;
}
type Handlers = {
    [C in Command]: {
        canHandle: (command: C) => boolean,
        handle: (command: C) => Promise<Response<C>>
    }
}
type Handler = Handlers[Command]

declare function createHandlers(): Handlers
export class AppCommandBus implements CommandBus {
  private readonly handlers: Handlers;
  constructor(){
    this.handlers = createHandlers();
  }
  public async execute<C extends Command>(
    command: C
  ): Promise<Response<C>> {
    if (!this.handlers[command].canHandle(command)) throw Error();
    return this.handlers[command].handle(command);
  }
}

Handler is now reference to a particular property value, so canHandle and handle both refer to same Command.

The type CommandTable is used to define and correlates commands and actions. It's not necessary define the correlation exactly that way but it is an easy way to do it, and you see it can be mapped to other structures (e.g. Handlers). (You could omit CommandTable and explicitly define Handlers, for example.)

Notice that majority of the implementation is deferred to createHandlers().

Airless answered 10/10, 2023 at 19:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.