Typescript härleder inte deltyp från Array.includes


Jag försöker få TypeScript att korrekt härleda vissa variabler med hjälp av Array.includes(), men det fungerar inte. Här är ett lättförståeligt exempel:

const Array = ['a', 'b', 'c'] as const
type SomeType = {
  a: string
  b: string
  c: string
  foo: boolean
  bar: boolean
}

Låt oss säga att jag har en funktion som tar emot en nyckel av SomeType men jag vill egentligen bara göra saker när min nyckel finns i min Array

const myAwesomeFunction = (field: keyof SomeType): void => {
  if (Array.includes(field) doStuff(field) 
}

Här bör typen av fält som ges till doStuff-funktionen vara typeof Array men det är faktiskt keyof SomeType

Jag antar att detta beteende beror på några obskyra kantfall, men jag skulle vilja hitta en lösning på detta eller åtminstone förstå det underliggande problemet i koden ovan och varför Typescript tycker att detta är ett misstag

Jag har hittat några problem relaterade på GH men jag kunde inte riktigt förstå allt :

  • Problem 1
  • Nummer 2

Svar #1

För närvarande tillåter inte TypeScript-typningarna för includes()-metoden på arrayer att den fungerar som en typvakt för dess indata. Detta föreslogs i microsoft/TypeScript#36275, men avslogs eftersom det skulle vara för komplext att implementera. Anledningen till att det inte tillåts har att göra med vad det innebär när includes() returnerar false. Det är lätt att resonera kring resultatet true; om arr.includes(x) är true, måste naturligtvis x ha samma typ som elementen i arr. Men om arr.includes(x) är false är det ganska ofta ett misstag att säga att typen av x inte är typen av elementen i arr. Till exempel, tänk på vad som skulle hända här:

function hmm(x: string | number) {
   if (!([1, 2, 3].includes(x))) {
      x.toUpperCase();
   }
}

Värdet x är antingen av typen string eller number. Om [1, 2, 3].includes(x) är true, då vet vi att x är ett number. Men om det är false, kan vi absolut inte dra slutsatsen att x är en string. Men det är vad som skulle hända om includes() var en typvaktfunktion. Det finns inget inbyggt stöd för att säga "åh, ignorera bara det negativa fallet". Det skulle kräva något i stil med "ensidiga" eller "finkorniga" typvaktsfunktioner, som efterfrågats i microsoft/TypeScript#15048, ett av de problem du nämnde.

Denna förändring skulle vara omedelbart märkbar och obehaglig, eftersom includes()-anrop i hela TypeScript-världen plötsligt skulle se mycket märkliga effekter där de sökta värdena blev felaktigt begränsade, möjligtvis hela vägen till never-typen.


Så det naiva sättet att göra det till en typvakt skulle vara ett problem. Vi skulle kunna försöka vara mer sofistikerade och endast tillåta att funktionen fungerar som en typvakt i omständigheter som i din kod, där arrayelementtypen är en union av literaltyper. Men detta börjar se mer komplicerat och underligt ut, och involverar villkorstyper och this-parametrar, kanske på detta sätt:

interface ReadonlyArray<T> {
   includes(
      this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
      searchElement: string
   ): searchElement is string & T;
}

Det försöker upptäcka om T är en strängliteraltyp, men inte string själv, och sedan gör det att funktionen fungerar som en typvakt. Men även det är inte säkert, eftersom inget kräver att en array innehåller varje element av sin typ:

function yuck(x: "a" | "c") {
   const arr: readonly ("a" | "b")[] = ["b"]; // <-- doesn't contain "a"
   if (!(arr.includes(x))) {
      x; // x has been erroneously narrowed to "c"
      ({ c: 123 })[x].toFixed(1); // runtime error
   }
}

Det kanske inte är troligt i praktiken, men det är en komplikation. Även om vi inte brydde oss om det, kan det att lägga till nya märkliga anrop signaturer till globalt tillgängliga funktioner ha märkbara effekter på andras kod. (Inferens gör konstiga saker med överlagrade funktioner, se ms/TS#26591 bland otaliga andra). Det är helt enkelt inte värt ansträngningen och risken för hela TypeScript bara för att stödja det här enda användningsfallet.


Om du bara gör detta en gång, skulle jag rekommendera att du bara hävdar det och går vidare:

const myAwesomeFunction = (field: keyof SomeType): void => {
   if (array.includes(field)) {
      doStuff(field as "a" | "b" | "c")  // assert
   } else {
      field as "foo" | "bar" // assert
   }
}

Annars kan du slå in det i din egen anpassade typvaktfunktion och använda den när du behöver detta beteende:

function myIncludes<T extends U, U>(
  arr: readonly T[], searchElement: U
): searchElement is T {
   return (arr as readonly any[]).includes(searchElement);
}

const myAwesomeFunction = (field: keyof SomeType): void => {
   if (myIncludes(array, field)) {
      doStuff(field)
   } else {
      field // (parameter) field: "foo" | "bar"
   }
}

Eller, om du verkligen vill se detta beteende överallt i din kodbas, kan du slå ihop den funktionssignaturen med Array<T>-gränssnittet; så här skulle det vara om microsoft/TypeScript#36275 hade blivit accepterad, men det påverkar ingen annan.

// declare global { // uncomment this if you're in a module
interface ReadonlyArray<T> {
   includes(
      this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
      searchElement: string
   ): searchElement is string & T;
}
// } // uncomment this if you're in a module


const myAwesomeFunction = (field: keyof SomeType): void => {
   if (array.includes(field)) {
      doStuff(field)
   } else {
      field // (parameter) field: "foo" | "bar"
   }
}

Playground-länk till kod

Relaterade artiklar