Typescript-objektindex
Jag kommer ganska bra överens med Typescript överlag, men jag stöter hela tiden på det här.
Säg att jag har en typ som kan vara en av ett urval av strängar:
type ResourceConstant = 'A' | 'B' | 'C';
Nu vill jag skapa ett objekt för att hålla mängden av varje resurs (alltså en mappning av ResourceConstant till number):
let x: { [key: ResourceConstant]: number } = {};
>>> error TS1337: An index signature parameter type cannot be a union type. Consider using a mapped object type instead.
Så vad är en "mappad objekttyp"? Jag har hittat viss information om "mappade typer", men det är inte klart hur de alls är relaterade till detta problem. Kanske menar de att jag borde använda Record:
let x: Record<ResourceConstant, number> = {};
>>> error TS2740: Type '{}' is missing the following properties from type 'Record<ResourceConstant, number>': U, L, K, Z, and 80 more.
Okej, så det behöver Partial:
let x: Partial<Record<ResourceConstant, number>> = {};
Det här fungerar, men det är verkligen hemskt.
Hur som helst, låt oss försöka använda detta någonstans:
for (let res in x) {
terminal.send(res, x[res]);
}
>>> error TS2345: Argument of type 'string' is not assignable to parameter of type 'ResourceConstant'.
Okej, så typinformationen har gått förlorad eftersom objekt alltid indexeras med strängar. Okej, jag säger bara till TypeScript att detta definitivt är en ResourceConstant:
for (let res: ResourceConstant in x) {
>>> error TS2404: The left-hand side of a 'for...in' statement cannot use a type annotation.
Kanske kan jag använda as för att tvinga typen:
for (let res as ResourceConstant in x) {
>>> error TS1005: ',' expected
Eller kasta med <>-syntaxen?
for (let res<ResourceConstant> in x) {
>>> error TS1005: ';' expected
Nej. Det verkar som att jag måste skapa en andra mellanvariabel för att tvinga typen:
for (let res in x) {
let res2 = res as ResourceConstant;
terminal.send(res2, x[res2]);
}
Detta fungerar, men det är också fruktansvärt.
Vilket kaos. Jag vet att det korrekta svaret är att jag borde använda new Map istället för {}, och det är allt väl och bra, men det här är JS - på gott och ont är vi vana vid att använda objekt för den här typen av saker. Viktigast av allt, vad händer om jag dokumenterar ett befintligt kodbas? Jag borde väl inte behöva ändra allt till Map bara för Typescripts skull?
Varför verkar det vara så svårt att arbeta med vanliga objekt? Vad missar jag?
Svar #1
Det utfördes viss arbete i microsoft/TypeScript#26797 om att tillåta indexsignaturer med godtyckliga egenskapsnyckeltyper. Tyvärr har det stannat upp eftersom det inte är klart hur man ska hantera en mismatch mellan mappad typ och indexsignaturens beteende. Indexsignaturer, som de är implementerade nu, har ett annat och mer osäkert beteende än mappade typer. Du märkte detta genom att anta att du kunde tilldela {} till en mappad typ vars nycklar är ResourceConstant, och fick beskedet nej eftersom egenskaperna saknas. Indexsignaturer bryr sig för närvarande inte om egenskaper saknas, vilket är bekvämt men osäkert. Viss arbetsinsats skulle behöva göras för att göra mappade typer och indexsignaturer mer kompatibla för att kunna gå vidare. Kanske kommer den kommande pedantiska indexsignaturer --noUncheckedIndexedAccess-funktionen att lösa detta? För nu måste du använda mappade typer.
Att konvertera från en indexsignatur till en mappad typ är tillräckligt enkelt syntaxmässigt: du kan byta från {[k: SomeKeyType]: SomeValueType} till {[K in SomeKeyType]: SomeValueType}. Detta är samma som hjälptypen Record<SomeKeyType, SomeValueType>. Och ja, om du vill utelämna vissa nycklar, kan du använda Partial<>.
Vissa människor gillar Partial<Record<K, T>> eftersom det är en mer "engelsk"-lik beskrivning av vad du gör. Ändå, om du tycker att det är fruktansvärt 😝, kan du minska din förskräckelse genom att skriva den mappade typen själv:
let x: { [K in ResourceConstant]?: number } = {};
Nu för for..in-loopgrejerna.
Det skulle definitivt vara trevligt om man fick annotera deklarationen av den itererande variabeln inuti for..in eller for..of-loopar. För närvarande kan det inte göras alls (se microsoft/TypeScript#3500 för mer information).
Men att låta dig annotera res som ResourceConstant skulle vara ett problem.
Den stora knäckfrågan här är att objekttyper i TypeScript är öppna, eller utbyggbara, och inte stängda, eller exakta. En objekttyp som {a: string, b: number} betyder "detta objekt har en egenskap med värdet string vid nyckeln a och en egenskap med värdet number vid nyckeln b", men det betyder inte "och det finns inga andra egenskaper". Extra egenskaper är inte förbjudna. Så ett värde som {a: "", b: 0, c: true} kan tilldelas. (Detta kompliceras av det faktum att om du försöker tilldela ett nytt objektbokstav med extra egenskaper till en variabel, utför kompilatorn faktiskt en kontroll av överflödiga egenskaper. Men dessa kontroller är lätta att kringgå; se dokumentationslänken för mer information).
Låt oss därför föreställa oss att vi skulle kunna skriva for (let res: ResourceConstant in x) utan varning:
function acceptX(x: { [K in ResourceConstant]?: number }) {
for (res: ResourceConstant in x) { // imagine this worked
console.log((x[res] || 0).toFixed(2));
}
}
Då hindrar inget mig från att göra detta:
const y = { A: 1, B: 2, D: "four" };
acceptX(y); // no compiler warning, but this explodes at runtime
// 1.00, 2.00, and then EXPLOSION!
Hoppsan. Jag antog att for (let res in x) bara skulle iterera över några av A, B och C. Men vid körning smög en D in där och ställde till allt.
Det är därför de inte låter dig göra det. I avsaknad av exakta typer är det helt enkelt inte säkert att iterera över vilka nycklar som råkar finnas i ett objekt av en "känd" typ. Så, du kan antingen vara säker på detta sätt:
for (let res in x) {
if (res === "A" || res === "B" || res === "C") {
console.log((x[res] || 0).toFixed(2)); // no error now
}
}
eller detta:
// THIS IS THE RECOMMENDED SOLUTION HERE
for (let res of ["A", "B", "C"] as const) {
console.log((x[res] || 0).toFixed(2));
}
ELLER, du kan vara osäker och använda en typförsäkran eller annan lösning. Den uppenbara lösningen med försäkran är att skapa en ny variabel på detta sätt:
for (let res in x) {
const res2 = res as ResourceConstant;
console.log((x[res2] || 0).toFixed(2));
}
Eller påstå varje gång du nämner res,
for (let res in x) {
console.log((x[res as ResourceConstant] || 0).toFixed(2));
}
men om det är för hemskt 😱 då kan du använda följande lösning (från den här kommentaren):
let res: ResourceConstant; // declare variable in outer scope
for (res in x) { // res in here uses same scope
console.log((x[res] || 0).toFixed(2));
}
Här har jag deklarerat res i den yttre omfattningen som den typ jag ville ha, och sedan använder jag den bara inne i for..in-loopen. Detta verkar fungera utan fel. Det är fortfarande osäkert, men kanske tycker du att det är mer acceptabelt än de andra alternativen.
Jag tror att din frustration med TypeScript är förståelig... men jag hoppas att jag har förmedlat att de murar du stöter på finns av en anledning. När det gäller mappade typer och indexsignaturer råkar muren koppla två användbara korridorer, men arkitekterna vet ännu inte hur man skär ett hål i den utan att ena sidan eller den andra kollapsar. När det gäller att annotera for..in-loopens indexerare är det för att stoppa folk från att falla ner i det öppna hålet på andra sidan av den muren.
Playground-länk till kod
