Typescript-union av nästlad typ förlorar nyckel som inte delas mellan typer
Jag tror att detta kan ligga längs samma linjer som Typescript - unionstyper, Problem med TypeScript-typer och Typescript-egenskap finns inte på unionstyp men ingen av dem är exakt samma. När en unionstyp skapas från Object.entries...map verkar det ta bort nycklar som inte är gemensamma. Här är lite exempel på kod:
type Params = {
head1:{
sub1:{
a:string
}},
head2:{
sub2:{
a:string,
b:number
}}}
(params:Params)=>{
Object.entries(params).map(([headkey, sub])=>{
Object.entries(sub).map(([subkey, subval])=>{
subval.a // <- Works fine
if('b' in subval)
subval.b // <- Error, "Property 'b' does not exist on type '{ a: string; }'"
})
})
}
Lekplats
Finns det något sätt att övervinna detta? Är detta en bugg?
Svar #1
Det är inte en bugg, även om det har effekter som du inte gillar i denna situation. När kompileraren måste härleda en enda typ från flera andra typer, kommer den att försöka syntetisera det som kallas "den bästa gemensamma typen". Ofta kommer detta att vara en direkt union av alla involverade typer:
interface X { x: string }
declare const x: X;
interface Y { y: string; }
declare const y: Y;
const eitherXY = Math.random() < 0.5 ? x : y
// const eitherXY: X | Y
Men ibland kommer åtminstone en av de involverade typerna att kunna tilldelas till en av de andra involverade typerna, och i dessa fall kommer kompilatorn att härleda endast den enda, mer generella typen istället för en union:
interface Z { x: string, z: number }
declare const z: Z;
const eitherXZ = Math.random() < 0.5 ? x : z
// const eitherXZ: X // <-- this is X, not X | Z
Detta fungerar eftersom även om det inte deklareras som sådant, Z extends X. Objekt typer i TypeScript är utvidgningsbara genom att lägga till egenskaper, på grund av strukturell subtypning. Och därför bestämmer kompilatorn sig för att härleda bara X istället för X | Z.
Nu, i en värld där TypeScript hade ett helt korrekt typsystem baserat på strukturell subtypning, kanske det inte finns någon observerbar skillnad mellan unionen X | Z och bara X om Z extends X. När allt kommer omkring kan man tänka på typer som motsvarande mängden av alla JavaScript-värden som kan tilldelas den typen. Om Z extends X så är varje värde av typen Z också ett värde av typen X, och därför motsvarar X | Z och X samma mängd värden. I den världen skulle valet mellan X | Z och bara X enbart vara en fråga om hur typen är visad.
Men vi lever inte i den världen.
Ett ställe där X | Z är tydligt annorlunda än X är vid kontroll av överskjutande egenskaper i objektliteraler:
const xOrZ: X | Z = { x: "hello", z: 123 }; // okay
const justX: X = { x: "hello", z: 123 }; // error, excess property
Varningar för överflödiga egenskaper är tekniskt sett falska positiva om man ser dem som typfel, så kanske är det bättre att se dem som en lintervarning istället.
En annan plats är när du använder in-operatorn som en typvakt:
declare const x: X;
if ("z" in x) {
x.z.toFixed(); // error
}
declare const xz: X | Z;
if ("z" in xz) {
xz.z.toFixed(); // okay
}
Detta är känt för att vara tekniskt felaktigt. Trots allt bör värdet {x: "", z: ""} vara ett giltigt värde av typen X och därmed ett giltigt värde av typen X | Z:
const hmm = { x: "", z: "" }
const alsoX: X = hmm; // okay
const alsoXZ: X | Z = hmm; // okay
Så det bör verkligen vara ett fel att föreställa sig att enbart närvaron av en z-egenskap innebär att det är ett number. Ingen av de ovanstående in-typbevakningarna borde fungera om vi var strikta. Men i praktiken är det mycket användbart att anta att du kan använda in som en typbevakning (och folk tenderar att vilja ha ännu mer; bara xz.z utan att först kontrollera "z" in xz, men detta har konsekvent avvisats).
Allt detta betyder att, beroende på hur du tänker kring strukturell subtypning och unioner, och om det överensstämmer med hur kompilatorn hanterar en typ eller ett uttryck, är du sannolikt att få korrekta-men-irriterande varningar eller inkorrekta-men-användbara avsaknader av varningar... ingen av dessa är buggar.
Så det finns ingen bugg här.
Det sagt, vad skulle du kunna göra för att övervinna det? I ditt fall är problemet att Object.entries() har följande anropssignatur:
/* (method) ObjectConstructor.entries<T>(o: {
[s: string]: T;
} | ArrayLike<T>): [string, T][] (+1 overload) */
Och när du anropar det på sub, härleder kompilatorn T som {a: string}, vilket är den bästa gemensamma övertypen av {a: string} och {a: string, b: number}:
Object.entries(sub)
/* (method) ObjectConstructor.entries<{ a: string; }>(...) */
Därför är det enklaste sättet att kringgå det att uttryckligen specificera T som den unionstyp du förväntade dig. Du kan antingen skriva ut det som {a: string} | {a: string, b: number}, eller så kan du låta kompilatorn beräkna den typen med hjälp av Params:
type SubSubParams =
{ [K in keyof Params]: Params[K][keyof Params[K]] }[keyof Params]
/* type SubSubParams = {
a: string;
} | {
a: string;
b: number;
} */
och sedan
Object.entries<SubSubParams>(sub).map(([subkey, subval]) => {
subval.a
if ('b' in subval)
subval.b // okay
})
Playground-länk till kod
