typescript - 'R' kunne bli instansiert med en vilkårlig type som kunne være uten tilknytning til 'Response'


Ok, så jeg prøver å implementere en enkel "Command Bus" i TypeScript, men jeg snubler over generics og jeg lurer på om noen kan hjelpe meg. Her er koden min:

Dette er grensesnittet for commandbus

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

Dette er implementeringen

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

og dette er hvordan Handler-grensesnittet ser ut:

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

Command er (for øyeblikket) et tomt grensesnitt, og Response ser slik ut:

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

Jeg får følgende kompilasjonsfeil mot den siste linjen i execute-funksjonen til commandbus, og jeg er helt rådvill.

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

Hvis noen kan hjelpe meg med å forstå hva jeg gjør galt, vil jeg være evig takknemlig!

REDIGER

Jeg har innsett at jeg kan omgå dette med en typekonvertering:

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

Men jeg vil fortsatt gjerne vite hvordan jeg kan løse denne doble kastingen.

Svar #1

Generiske funksjoner i TypeScript fungerer som en funksjon som representerer hver mulig spesifikasjon av dens generiske typeparametere, siden det er anroperen av funksjonen som spesifiserer typeparameteren, ikke implementøren:

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}

Så, la oss se på CommandBus:

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

Metoden execute() i CommandBus er en generisk funksjon som hevder å kunne akseptere en command av hvilken som helst subtype av Command som kaller ønsker (ok så langt antagelig), og returnere en verdi av Promise<R>, hvor R er hvilken som helst subtype av Response<C> som kaller ønsker. Det virker ikke som noe hvem som helst kunne plausibelt implementere, og antageligvis må du alltid påstå at responsen du returnerer er R som kaller ba om. Jeg tviler på at dette er din intensjon. I stedet, hva med noe sånt som dette:

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

Her har execute() bare én generisk parameter, C, som tilsvarer typen til den innsendte command. Og returverdien er bare Promise<Response<C>>, ikke en eller annen subtype som kallet ber om. Dette er mer plausibelt implementerbart, så lenge du har en måte å garantere at du har en passende håndterer for hver C (for eksempel ved å throwe hvis du ikke har).


Det leder oss til ditt Handler-grensesnitt:

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

Selv om vi frigjør oss fra tyranniet av å prøve å representere den spesifikke undertypen av Response<C> som en handler vil produsere, som dette:

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

vi har fortsatt et problem med canHandle(). Og det er det faktum at Handler selv er en generisk type. Forskjellen mellom generiske funksjoner og generiske typer har å gjøre med hvem som får spesifisere typeparameteren. For funksjoner er det kaller. For typer er det implementøren:

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

Du ønsker at Handler<C> kun skal handle() en kommando av typen C, noe som er greit. Men canHandle()-metoden krever også at kommandoen skal være av typen C, noe som er for strengt. Du ønsker at den som kaller canHandle() skal velge C, og at returverdien skal være true eller false avhengig av om C valgt av den som kaller samsvarer med den valgt av implementøren. For å representere dette i typesystemet, vil jeg foreslå å gjøre canHandle() til en generisk, brukerdefinert type guard-metode av et overordnet grensesnitt som ikke er generisk i det hele tatt, slik:

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

Så, hvis du har en SomeHandler, alt du kan gjøre er å kalle canHandle(). Hvis du gir den en kommando av typen C, og canHandle() returnerer true, vil kompilatoren forstå at din handler er en Handler<C>, og du kan kalle den. Slik:

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

Vi er nesten ferdige. Den eneste kronglete biten er at du bruker SomeHandler[]'s find()-metode for å finne en som passer for command. Kompilatoren kan ikke se inn i callbacken handler => handler.canHandle(command) og utlede at callbacken er av typen (handler: SomeHandler) => handler is SomeHandler<C>, så vi må hjelpe den ved å annotere den som sådan. Da vil kompilatoren forstå at returverdien av find() er 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);
    }
}

Dette fungerer ganske bra med typesystemet og er så bra som jeg kan få det. Det kan hende det fungerer for ditt faktiske brukstilfelle, eller ikke, men forhåpentligvis gir det deg noen ideer om hvordan generiske typer kan brukes effektivt. Lykke til!

Lenke til kode på lekeplassen

Relaterte artikler