Typescript 5.0.3 - Varför genererar inte dessa typer med inkompatibla generiska parametrar ett fel?
------------- Motivation och sammanhang (Inte nödvändigt för att förstå frågan)----------------
I min TypeScript-app har jag objekt med referenser till andra objekt. Ibland är dessa referenser ifyllda, ibland inte. Exempel:
type Entity = {
ref1: string | Obj,
ref2: string | Obj
}
Jag ville införa en generell parameter T som tillåter mig att specificera vilka av props som är ifyllda. Om jag inte skickar T, måste alla referenser vara strängar. Om jag skickar T={ref1: Obj}, måste ref1 vara ifyllt, men ref2 får inte vara det. Och om jag skickar T={ref1:Obj, ref2:Obj}, måste båda referenserna vara ifyllda. Det mest flexibla sättet att skriva detta visade sig vara något liknande:
type Entity<T extends Partial<{ref1: string|Obj, ref2:string|Obj}> = {}> = {
ref1: T["ref1"] extends string|Obj ? T["ref1"] : string,
ref2: T["ref2"] extends string|Obj ? T["ref2"] : string,
}
---------- Frågan/buggen ------------------------
Allt fungerade bra tills jag insåg en plats i min kod där TypeScript borde ha kastat ett fel, men inte gjorde det. Jag förenklade ovanstående typ för att undersöka bättre, eftersom jag vet att unionstyper med konditionella typer kan orsaka oväntat beteende. Jag kom fram till detta konstiga resultat i TypeScript Playground:
--- Minimal reproducerbart exempel (Playground-länk) ---
type Entity<T extends {ref1?:unknown}> = {
ref1: T["ref1"] extends string ? T["ref1"]: number;
}
type A = Entity<{ref1: string}> extends Entity<{}> ? true : false // correctly true
type B = Entity<{ref1: number}> extends Entity<{}> ? true : false // Incorrectly true
type C = Entity<{ref1: number}> extends Entity<{ref1: undefined}> ? true : false // result: correctly returns false
type D = Entity<{ref1: string}> extends Entity<{ref1: undefined}> ? true : false // result: Incorrectly returns false
Men det är meningslöst: I typ B förenklas Entity<{ref1: number}> till en typ av {ref1: number} medan Entity<{}> förenklas till en typ av {ref1: string} vilket är oförenligt.
När jag är mer explicit, i typ C, förstår TypeScript korrekt.
Finns det något jag inte förstår om TypeScript som förklarar det beteendet, eller är det en TypeScript-bugg?
Svar #1
En grundläggande egenskap hos TypeScripts typ-system är att det är strukturellt och inte nominellt. Detta står i kontrast till typ-system som Java:s, där interface A {} och interface B {} anses vara olika typer eftersom de har olika deklarationer. I TypeScript, när typerna A och B har samma struktur så är de samma typ, oavsett deras deklarationer. TypeScripts typ-system är strukturellt.
Förutom när det inte är det.
Att jämföra två typer strukturellt kan vara dyrt för typkontrollern. Föreställ dig två djupt nästlade eller till och med rekursiva typer X och Y, och kompilatorn måste kontrollera om X är en subtyp av Y eftersom du försöker tilldela ett värde av typen X till en variabel av typen Y. Kompilatorn behöver börja kontrollera varje egenskap hos Y mot X, och varje subegenskap, och varje subsubegenskap, tills den hittar en inkompatibilitet eller tills den når en punkt där den kan sluta kontrollera (antingen når den slutet på trädstrukturen eller hittar något den redan har kontrollerat). Om detta vore den enda typjämförelsemetoden som fanns tillgänglig för TypeScript-kompilatorn, skulle den vara mycket långsam.
Så istället tar kompilatorn ibland en genväg. När du definierar en generisk objekttyp mäter kompilatorn typens varians (se Skillnaden mellan varians, kovarians, kontravarians och bivarians i TypeScript) från dess definition och markerar den som sådan för senare användning. Till exempel i:
interface Cov<T> { f: () => T }
interface Contrav<T> { f: (x: T) => void; }
interface Inv<T> { f: (x: T) => T; }
interface Biv<T> { f(x: T): void; }
kompilatorn markerar dessa som kovariant, kontravariant, invariant och bivariat, respektive. Det är logiskt med typen systemet:
declare let ca: Cov<"a">
declare let cs: Cov<string>;
cs = ca; // okay
ca = cs; // err
declare let cna: Contrav<"a">
declare let cns: Contrav<string>;
cns = cna; // err
cna = cns; // okay
declare let ia: Inv<"a">
declare let is: Inv<string>;
is = ia; // err
ia = is; // err
declare let ba: Biv<"a">
declare let bs: Biv<string>;
bs = ba; // okay
ba = bs; // okay
När kompilatorn tilldelar dessa markörer till en komplicerad generisk typ F<T>, kan mycket tid sparas när man jämför F<X> med F<Y>. Istället för att behöva stoppa in X och Y i F och sedan jämföra resultaten, kan den bara jämföra X och Y och använda variansmarkören på F för att beräkna det önskade resultatet. I sådana fall kan kompilatorn helt enkelt behandla F<T> som en svart låda.
Om du har en annan komplicerad generisk typ G<T>, kan dock inte kompilatorn använda en genväg för att jämföra F<X> med G<Y>, eftersom G kan vara helt orelaterad till F. Även om det visar sig att F och G:s definitioner är desamma, skulle kompilatorn inte veta det om den inte jämför dessa definitioner, vilket betyder att den svarta lådan måste öppnas. En fullständig strukturell jämförelse är det enda alternativet här.
Så här har vi en situation där F<X> extends G<Y> resulterar i en kodväg för kompilatorn, men F<X> extends F<Y> resulterar i en annan kodväg. Det ser verkligen ut som att kompilatorn jämför dessa typer nominellt, baserat på deras deklarationer.
Men en sådan skillnad är omöjlig att observera, eller hur? Eftersom typ systemet är helt korrekt, eller hur? Om kompilatorn tilldelar en variansmarkör gör den det korrekt, eller hur? Och subtypning är en transitiv relation, så om X extends Y och Y extends Z då X extends Z, och om F<T> är markerad som kovariant i T då F<X> extends F<Y> och F<Y> extends F<Z> och även F<X> extends F<Z>, eller hur?
Visst?
Nej, självklart inte, inte helt. TypeScripts typsystem är avsiktligt osäkert på ställen där bekvämlighet har ansetts vara viktigare. Ett sådant ställe är med valfria egenskaper och valfria parametrar. Kompilatorn låter dig tilldela ett värde av typen {x: string} till en variabel av typen {x: string, y?: number}, för bekvämlighet:
interface X { x: string }
interface YN extends X { y?: string }
let x: X = { x: "" };
let yn: YN = x; // okay, no compiler error
Men detta är osäkert, eftersom en saknad egendomsdefinition egentligen har ett helt okänt värde vid körning:
interface YB extends X { y: boolean }
const yb: YB = { x: "", y: true }
x = yb; // okay, every YB is an X
yn = x; // okay? no compiler error but... wait:
yn.y?.toUpperCase(); // runtime error
Så du har en situation där YB extends X och X extends YN är sanna, men YB extends YN är falsk.
Se microsoft/TypeScript#42479 och de problem som är länkade inom för mer information.
Om du har en typpfunktion F<T> som är markerad som kovariant i T, vad borde hända då? Förmodligen är F<YB> extends F<X> och F<X> extends F<YN> sanna, men detta kan lätt brytas om F indexerar in i den valfria egenskapen och gör något med dess typ. Det är mer eller mindre vad som händer med din kod:
type Entity<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
// ^? type UsingVarianceMarker = true
Kompilatorn tror att Entity är kovariant (eller kanske bivariat) och därför är resultatet true eftersom {ref1: string} extends {}. Men att indexera egenskapen ref1 i {} är unknown, och därför borde Entity egentligen inte vara kovariant (kanske?):
type Entity2<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingStructural = Entity<{ ref1: string }> extends Entity2<{}> ? true : false
// ^? type UsingStructural = false
Kanske tilldelas variationsmarkören felaktigt? Det är svårt att säga. Med tanke på osäkerheten kring valfria egenskaper finns det förmodligen ingen "korrekt" markör och kompilatorn väljer bara något som fungerar bra i många olika verkliga situationer. Så är det ibland; se microsoft/TypeScript#43608 till exempel.
Så vad kan göras? Tja, du kan skriva om helt, men i fall där du inte gillar den variansmarkör som tilldelats av kompilatorn, kan du tilldela din egen (så länge kompilatorn inte ser en konflikt) med hjälp av de valfria typparameter-variansanteckningarna in (för kontravarians), out (för kovarians) eller in out (för invarians). Så kanske:
type Entity<in out T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
// ^? type UsingVarianceMarker = false
vilket får Entity att bete sig som om det vore invariant i sin parameter, och därför kommer Entity<T> extends Entity<U> vara falskt om inte T och U är samma typ. Det är inte nödvändigtvis vad du vill göra, och det är inte sant i allmänhet, men det är åtminstone en hävstång du kan dra för att ändra detta beteende.
Playground-länk till kod
