ウェブサイト検索

typescript - 'R' は 'Response' と無関係な任意の型でインスタンス化される可能性があります


わかりました、それで私は TypeScript でシンプルな「コマンドバス」を実装しようとしているのですが、ジェネリックでつまずいていて、誰か助けてくれるかどうか気になっています。こちらが私のコードです:

これはコマンドバスのインターフェースです

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

これは実装です

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

そして、これが Handler インターフェースの見た目です:

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

Command は(現在)空のインターフェースで、Response はこのようになっています:

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

私は、commandbus の execute 関数の最後の行で次のコンパイルエラーが発生しており、完全に行き詰まっています。

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

もし誰かが私が何を間違っているのか理解する手助けをしてくれるなら、私は永遠に感謝します!

編集

型変換を使えばこれを回避できることに気付きました:

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

でも、私はまだこのダブルキャストをどう解決するか知りたいです。

回答 #1

TypeScript のジェネリック関数は、そのジェネリック型パラメータのあらゆる可能な仕様を表す関数として機能します。なぜなら、型パラメータを指定するのは関数の 実装者 ではなく、呼び出し側 だからです:

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}

では、CommandBusを見てみましょう:

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

CommandBus の execute() メソッドは、呼び出し元が望む Command の任意のサブタイプの command を受け入れることができると主張するジェネリック関数であり(ここまではおそらく問題ない)、Promise 型の値を返します。ここで R は 呼び出し元が望む Response の任意のサブタイプ です。これは、誰も現実的に実装できそうにないことのように見えますし、返すレスポンスが呼び出し元が求めた R であると常にアサートする必要があるでしょう。おそらくこれが意図するところではないでしょう。代わりに、次のような形はどうでしょうか:

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

ここで、execute() は渡された command の型に対応する 1 つのジェネリックパラメータ C のみを持っています。そして、戻り値は呼び出し側が求めるようなサブタイプではなく、単に Promise<Response<C>> です。これは、すべての C に対して適切なハンドラーがあることを保証する方法(例えば、なければ throw する)さえあれば、より実装可能性が高いです。


それがあなたの Handler インターフェースにつながります:

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

たとえ、ハンドラーが生成する Response<C> の特定のサブタイプを表現しようとする専制から自分たちを解放したとしても、次のように:

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

私たちはまだ canHandle() に問題があります。そしてその理由は、Handler 自体がジェネリックな 型 であるということです。ジェネリックな 関数 とジェネリックな 型 の違いは、誰が型パラメータを指定するかに関係しています。関数の場合、それは呼び出し側です。型の場合、それは実装者です。

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

あなたは Handler<C> が型 C のコマンドだけを handle() するようにしたいわけで、それは問題ありません。しかし、その canHandle() メソッドも 型 C のコマンドであること を要求しており、これは厳しすぎます。あなたが望むのは、呼び出し側 が canHandle() を呼ぶときに C を選択でき、その戻り値が呼び出し側が選んだ C と実装者が選んだものが一致するかどうかによって true または false になることです。これを型システムで表現するには、canHandle() をジェネリックではない親インターフェースのユーザー定義型ガードメソッドにすることを提案します。例えば次のように:

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

ですので、もしあなたが SomeHandler を持っている場合、できることは canHandle() を呼び出すことだけです。もし C 型のコマンドを渡して、canHandle() が true を返した場合、コンパイラはあなたのハンドラが Handler<C> であると理解し、呼び出すことができます。このように:

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

もう少しで終わります。唯一面倒なのは、SomeHandler[] の find() メソッドを使って command に適したものを探している点です。コンパイラはコールバック handler => handler.canHandle(command) の中身を見て、そのコールバックが (handler: SomeHandler) => handler is SomeHandler<C> 型であることを推論できないので、型注釈を付けてあげる必要があります。そうすると、コンパイラは find() の戻り値が 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);
    }
}

これは型システムとかなりうまく機能し、できる限りきれいに作りました。実際の使用例でうまくいくかどうかはわかりませんが、ジェネリクスを効果的に使う方法についていくつかのアイデアを与えられることを願っています。頑張ってください!

コードへのプレイグラウンドリンク

関連記事