Welcome to WuJiGu Developer Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
1.6k views
in Technique[技术] by (71.8m points)

typescript - 'R' could be instantiated with an arbitrary type which could be unrelated to 'Response<Command>'

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.

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

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


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to WuJiGu Developer Q&A Community for programmer and developer-Open, Learning and Share
...