Typ av alla överlagrade parametertyper i TypeScript


Överväg följande exempel:

class A {
  private constructor(public n: number) {}
  public getDouble() {
    return this.n * 2;
  }
  static from(s: string): A;
  static from(n: number): A;
  static from(n1: number, n2: number): A;
  static from(...args: unknown[]): A {
    if (args.length === 1) {
      if (typeof args[0] === 'string') {
        if (args[0].length !== 1) {
          throw new Error('String must have a length of 1')
        }
        return new A(Number(args[0]));
      } else if (typeof args[0] === 'number') {
        if (args[0] > 9) {
          throw new Error('Number must be lower than 10')
        }
        return new A(args[0]);
      }
    } else if (args.length === 2) {
      if (typeof args[0] === 'number' && typeof args[1] === 'number') {
        const sum = args[0] + args[1];
        if (sum > 9) {
          throw new Error('Sum of numbers must be lower than 10')
        }
        return new A(sum);
      }
    }
    throw new Error('No overload matched')
  }
}

En instans av A kan endast skapas med den statiska metoden from, som är överlagrad för att ta antingen en sträng, ett tal eller två tal. Giltiga argument inkluderar endast strängar med längden 1, tal under 10 och par av tal vars summa är mindre än 10 - andra resulterar i ett fel.

Jag skulle vilja implementera en annan statisk metod validate, som skulle kontrollera om en instans av A kan konstrueras från en given lista med argument, på detta sätt:

static validate(...args: Parameters<typeof A.from>): boolean {
  try {
    A.from(...args);
    return true;
  } catch (error) {
    return false;
  }
}

Problemet är att Parameters<typeof A.from> löser till parametrarna för den sista överlagringen i listan, i det här fallet n1: number, n2: number, vilket gör att TypeScript inte tillåter anrop med några andra överlagringssignaturer, dvs. A.validate('foo').

Hur kan jag få TypeScript att förstå att validate kan anropas med samma överlagringssignaturer som from?

Jag har hittat två möjliga lösningar men båda har stora nackdelar:

  1. Jag skulle manuellt kunna konstruera en annan överlagrad signatur som matchar alla tidigare överlagringar och sätta den i slutet av listan. Men det skulle vara ganska svårt att underhålla och skulle störa IntelliSense-förslag.
  2. Som 1., men signaturen skulle tillåta ...args: unknown[]. Detta skulle dock få TypeScript att tro att from faktiskt kan anropas med vilka argument som helst när det inte kan det.
  3. Jag skulle kunna kopiera och klistra in alla from overload-signaturer och deklarera dem för validate också. Återigen, svårt att underhålla - det faktiska användningsfallet involverar många fler overloads samt mer än en statisk metod som imiterar from overloads, vilket skulle resultera i mer än 50 rader med overload-deklarationer.

Finns det något annat sätt att lösa detta? Finns det ett sätt att skriva en OverloadParameters-typ?

Svar #1

Om du har en överlagrad funktion/metod där varje anropssignatur har samma returtyp (som är fallet för from() där returtypen alltid är A), kan du refaktorera funktionen till en version med en enkel anropssignatur med en restparameter vars typ är en union av resttupler:

static from(...args: [s: string] | [n: number] | [n1: number, n2: number]): A {
  // same impl as before
}  

Detta kan anropas precis som tidigare:

A.from("okay"); // works
A.from(1); // okay
A.from(2, 4); // okay
A.from("oops", 2); // error!

Faktum är att det till och med ser ut som en överbelastad funktion ur IntelliSense:s synvinkel:

/* A.from(⎀) */
// 1/3 from(s: string): A
// 2/3 from(n: number): A
// 3/3 from(n1: number, n2: number): A

Men avgörande är att Parameters<typeof A["from"]> nu är hela unionen av tupler och förlorar ingen information:

static validate(...args: Parameters<typeof A.from>): boolean {
  // same impl as before
}

och du kan verifiera att A.validate() kan kallas på exakt samma sätt som A.from():

A.validate("okay"); // works
A.validate(1); // okay
A.validate(2, 4); // okay
A.validate("oops", 2); // error!

Lekplatslänk till kod

Svar #2

Jag har hittat en lösning baserad på lösning nummer 3. Jag försökte lista ut om det finns ett sätt att deklarera flera medlemmar som delar samma lista över överlagringar. Det är inte snyggt men jag tror inte att det finns ett mer elegant sätt att hantera detta.

Lösningen innebär att konvertera klassen till ett typat klassexpressions och skriva en generisk typ för överlagringen. Fortsätter man exemplet från frågan skulle överlagringstypen se ut så här:

type MyOverload<ReturnType> = {
  (s: string): ReturnType;
  (n: number): ReturnType;
  (n1: number, n2: number): ReturnType;
}

Sedan deklarerar vi ett gränssnitt för klassen och dess konstruktor:

interface AInterface {
  getDouble(): number;
}

interface AConstructor {
  new (n: number): AInterface;
  from: MyOverload<AInterface>;
  validate: MyOverload<boolean>;
}

Och slutligen klassimplementeringen, utan att deklarera överlagringar:

const A: AConstructor = class A implements AInterface {
  constructor(public n: number) {}
  static from(...args: unknown[]): A {
    // same as before
  }
  static validate(...args: unknown[]): boolean {
    try {
      A.from(...args);
      return true;
    } catch (error) {
      return false;
    }
  }
}

Vad detta uppnår:

  • Du kan deklarera flera metoder med samma lista av överlagrade parametrar men olika returtyper.
  • Deras anropssignaturer kommer att vara korrekt typade och fungera med IntelliSense.

Begränsningar (såvitt jag vet):

  • Du behöver deklarera klassens publika sida i gränssnitten och sedan deklarera om och implementera den i klassen, och hålla dessa två deklarationer synkroniserade. Detta är uppenbarligen en fördel för överlagrade metoder, men inte lika mycket för de andra, och skulle vara en börda för underhållet.
  • Inga skyddade medlemmar, varken instans- eller statiska (eftersom gränssnitt och typer inte kan deklarera privata/skyddade medlemmar).
  • Att tillhandahålla ytterligare argument till de överlagrade metoderna kommer att vara klumpigt.

Du skulle kunna ytterligare generalisera överlastningstypen för att ta ett ytterligare argument eller flera (t.ex. för en metod som createAndMultiplyBy):

type MyOverload<ReturnType, MethodArg = never> = {
  (s: string, arg: MethodArg): ReturnType;
  (n: number,  arg: MethodArg): ReturnType;
  (n1: number, n2: number, arg: MethodArg): ReturnType;
}

Och sedan deklarera metoden som createAndMultiply: MyOverload<number, number>. De extra argumenten skulle dock inte få några namn, och om det fanns flera (t.ex. type MyOverload<ReturnType, MethodArgs extends any[]=never>) skulle IntelliSense behöva visa dem som, för MyOverload<any, [number, number]> - (s: string, ...args: [number, number]).

För mitt användningsfall skulle dessa ytterligare överlagrade metoder bara vara ett extra lager av sockerladdad användbarhet. Jag bestämde mig för att kostnaden för att underhålla dessa extra gränssnitt inte var värd det, och gav upp att implementera dem.

Relaterade artiklar