javascript - TypeScript-klassfabrik förväntar sig en intersection


Jag har följande klassfabrik pickSomething som skapar en typ baserat på en överlämnad nyckel från en ClassMap:

class A {
  keya = "a" as const;
}
class B {
  keyb = "b" as const;
}

type ClassMap = {
  a: A
  b: B
}


const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
  switch (key) {
    case 'a':
      return new A(); // Error: A is not assignable to A & B
    case 'b':
      return new B(); // Error: B is not assignable to A & B
  }
  throw new Error();
}

// It works fine externally
const a = pickSomething('a').keya;
const b = pickSomething('b').keyb;

Det fungerar bra externt (som du kan se från const a=pickSomething('a').keya;). Detta betyder att externt ClassMap[K] mappas till rätt instans (A eller B beroende på den angivna key). Men internt får jag ett fel på varje return-sats. TypeScript förväntar sig att ClassMap[K] ska betyda A & B. Finns det ett sätt att lösa det med en bättre typannotering (utan att använda typförvängningar)?

Svar #1

Jag tror att det generella problemet är att TypeScript inte smalnar av en typparameter som utvidgar en union via kontrollflödesanalys, på det sätt som det normalt gör för värden av specifika unionstyper. Se microsoft/TypeScript#24085 för diskussion. Du har kontrollerat om key är "a" eller "b", och key är av typen K, men detta har inga konsekvenser för K självt. Och eftersom kompilatorn inte vet att K är något smalare än "a" | "b", vet den inte att ClassMap[K] kan vara något bredare än A & B. (Sedan TypeScript 3.5 kräver skrivning till en uppslagsnyckel på en union av nycklar en intersection av egenskaper; se microsoft/TypeScript#30769.)

Tekniskt sett är det korrekt att kompilatorn vägrar göra denna inskränkning, eftersom ingenting hindrar typparametern K från att anges som den fullständiga unionstypen "a" | "b", även om du kontrollerar det:

pickSomething(Math.random() < 0.5 ? "a" : "b"); // K is "a" | "b"

För närvarande finns det inget sätt att tala om för kompilatorn att du egentligen inte menar K extends "a" | "b", utan istället något som K extends "a" eller K extends "b"; det vill säga, inte en begränsning till en union, utan en union av begränsningar. Om du skulle kunna uttrycka det skulle det kanske vara möjligt för kontrollen av key att begränsa K självt och sedan förstå att, till exempel, ClassMap[K] bara är A när key är "a". Se microsoft/TypeScript#27808 och microsoft/TypeScript#33014 för de relevanta funktionsförfrågningarna där.

Eftersom de inte är implementerade, är det enklaste sättet att få din kod att kompilera med minimala ändringar att använda typantaganden. Det är förstås inte helt typ-säkert:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    switch (key) {
        case 'a':
            return new A() as A & B
        case 'b':
            return new B() as A & B
    }
    throw new Error();
}

men JavaScript-koden som produceras är i alla fall idiomatisk.


Andra möjligheter: kompilatorn tillåter dig att returnera en uppslagspropperty typ T[K] genom att faktiskt slå upp en egenskap av nyckeltyp K på ett objekt av typen T. Du kan refaktorera din kod för att göra detta:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    return {
        a: new A(),
        b: new B()
    }[key];
}

Om du inte vill skapa new A() och new B() varje gång du anropar pickSomething, kan du istället använda getters, så att endast den önskade kodvägen faktiskt följts:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    return {
        get a() { return new A() },
        get b() { return new B() }
    }[key];
}

Detta kompilerar utan fel och är typ-säkert. Men det är konstig kod, så jag vet inte om det är värt det. Jag tror att en typförsäkran, för tillfället, är rätt väg att gå. Och förhoppningsvis kommer det vid någon tidpunkt att finnas en bättre lösning på microsoft/TypeScript#24085 som gör att din ursprungliga kod fungerar utan behov av försäkran.


Lekplatslänk till kod

Relaterade artiklar