Website-Suche

typescript - 'R' könnte mit einem beliebigen Typ instanziiert werden, der möglicherweise nicht mit 'Response' zusammenhängt


Ok, also versuche ich, einen einfachen "Command Bus" in TypeScript zu implementieren, aber ich stolpere über Generika und frage mich, ob mir jemand helfen könnte. Hier ist mein Code:

Dies ist die Schnittstelle für den Commandbus

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

Dies ist die Implementierung

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);
  }
}

und so sieht die Handler-Schnittstelle aus:

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

Command ist (derzeit) ein leeres Interface, und Response sieht so aus:

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

Ich erhalte den folgenden Kompilierungsfehler auf der letzten Zeile der execute-Funktion des CommandBus und ich bin völlig ratlos.

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

Wenn jemand mir helfen kann zu verstehen, was ich falsch mache, wäre ich ewig dankbar!

BEARBEITEN

Ich habe festgestellt, dass ich dies mit einem Typumwandlung umgehen kann:

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

Aber ich würde trotzdem gerne wissen, wie man diesen doppelten Cast löst.

Antwort #1

Generische Funktionen in TypeScript fungieren als eine Funktion, die jede mögliche Spezifikation ihrer generischen Typparameter darstellt, da es der Aufrufer der Funktion ist, der den Typparameter festlegt, nicht der Implementierer:

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}

Also, schauen wir uns CommandBus an:

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

Die execute()-Methode von CommandBus ist eine generische Funktion, die behauptet, ein command beliebigen Subtyps von Command akzeptieren zu können, den der Aufrufer möchte (bis hierhin in Ordnung, wahrscheinlich), und einen Wert von Promise<R> zurückzugeben, wobei R ein beliebiger Subtyp von Response<C> ist, den der Aufrufer möchte. Das scheint etwas zu sein, das niemand plausibel umsetzen könnte, und vermutlich muss man immer sicherstellen, dass die zurückgegebene Antwort tatsächlich das R ist, das der Aufrufer verlangt. Ich bezweifle, dass dies Ihre Absicht ist. Stattdessen, wie wäre es mit so etwas:

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

Hier hat execute() nur einen generischen Parameter, C, der dem Typ des übergebenen command entspricht. Und der Rückgabewert ist einfach Promise<Response<C>>, nicht irgendein Subtyp, den der Aufrufer verlangt. Dies ist plausibler implementierbar, solange man sicherstellen kann, dass für jedes C ein geeigneter Handler vorhanden ist (zum Beispiel durch throw, wenn dies nicht der Fall ist).


Das führt uns zu Ihrem Handler-Interface:

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

Selbst wenn wir uns von der Tyrannei befreien, zu versuchen, den bestimmten Subtyp von Response<C>, den ein Handler erzeugen wird, darzustellen, wie folgt:

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

Wir haben immer noch ein Problem mit canHandle(). Und zwar die Tatsache, dass Handler selbst ein generischer Typ ist. Der Unterschied zwischen generischen Funktionen und generischen Typen besteht darin, wer den Typparameter angeben darf. Bei Funktionen ist es der Aufrufer. Bei Typen ist es der Implementierer:

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");

Sie möchten, dass Handler<C> nur handle() für einen Befehl des Typs C ausführt, was in Ordnung ist. Aber seine canHandle()-Methode verlangt auch, dass der Befehl vom Typ C ist, was zu streng ist. Sie möchten, dass der Aufrufer von canHandle() das C auswählt und der Rückgabewert true oder false ist, je nachdem, ob das vom Aufrufer gewählte C mit dem vom Implementierer gewählten übereinstimmt. Um dies im Typsystem darzustellen, würde ich vorschlagen, canHandle() zu einer generischen, vom Benutzer definierten Type-Guard-Methode einer übergeordneten Schnittstelle zu machen, die überhaupt nicht generisch ist, so:

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>>;
}

Also, wenn Sie einen SomeHandler haben, können Sie nur canHandle() aufrufen. Wenn Sie ihm einen Befehl vom Typ C übergeben und canHandle() true zurückgibt, wird der Compiler verstehen, dass Ihr Handler ein Handler<C> ist und Sie ihn aufrufen können. So:

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!
    }
}

Wir sind fast fertig. Der einzige knifflige Punkt ist, dass Sie die find()-Methode von SomeHandler[] verwenden, um einen passenden für command zu finden. Der Compiler kann nicht in den Callback handler => handler.canHandle(command) hineinsehen und ableiten, dass der Callback vom Typ (handler: SomeHandler) => handler is SomeHandler<C> ist, daher müssen wir ihm helfen, indem wir ihn entsprechend annotieren. Dann wird der Compiler verstehen, dass der Rückgabewert von find() Handler<C> | undefined ist:

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);
    }
}

Das funktioniert ziemlich gut mit dem Typsystem und ist so schön, wie ich es machen kann. Es mag für Ihren tatsächlichen Anwendungsfall funktionieren oder auch nicht, aber hoffentlich gibt es Ihnen einige Ideen, wie Generika effektiv genutzt werden können. Viel Glück!

Playground-Link zum Code

Verwandte Artikel