Vi brukade inte kunna komma åt alla egenskaper samtidigt från en union i TypeScript?


Som titeln. Jag köpte en onlinekurs som jag inte hade tid att titta på förrän nu. I kursen står det något i stil med detta var inte tillåtet i TypeScript:

type Name = { name: string }
type Age = { age: number }

type UnionBoth = Name | Age

const test = (union: UnionBoth): void => {
    if ('name' in union && 'age' in union) {
        union.name
        union.age
    }
}

Men efter att ha testat på den nuvarande versionen 5.4.3 fungerar det utan fel. Jag började testa samma kod på föregående version och upprepade det. Till slut stötte jag på ett fel på Playground vid 4.8.4:

Property 'name' does not exist on type 'never'.
Property 'age' does not exist on type 'never'.

Någon förklaring? Kursen försökte "ändra" min mentala modell genom att säga att förening kanske inte är det intuitiva sättet vi tänker. Nu är detta inte ett fel, så vad är den korrekta mentala modellen för att förstå koden ovan, korrekt?


Just kollade att koden på 5.4.3 inferenstypen av union i funktionen är av typen:

(parameter) union: Name & Record<"age", unknown>

Varför finns det ett Record här bara för egenskapen age?


Min nuvarande mentala modell:

  1. Det finns två uppsättningar Name och Age.
  2. Unionen union av funktionen kan falla in i ett av de tre fallen:

    1. det finns endast i Name.
    2. det är endast i Age.
    3. det är i skärningspunkten, det vill säga Name & Age.
  3. Genom typavgränsningen av if-satsen bör TypeScript förstå att fallet är 3 och vi bör kunna komma åt både name och age inne i blocket.

Svar #1

TypeScript har alltid haft in-operatorn för förträngning så att if ("name" in val) skulle tjänstgöra för att förtränga {name: string} | {age: number} till {name: string}. TypeScript 4.9 introducerade obevakat attribut-förträngning med in-operatorn, så att if ("age" in val) skulle förtränga {name: string} till motsvarande {name: string; age: unknown}. Ja, dessa funktioner är ömsesidigt inkonsekventa. Du kan läsa implementerings-pullrequesten på microsoft/TypeScript#50666, och funktionsförfrågan på microsoft/TypeScript#21732 som diskuterar hur folk vill att in-operatorn ska fungera, och de avvägningar som är involverade.

Det finns ingen "korrekt" mental modell som fungerar i alla situationer. TypeScript försöker stödja idiomatisk JavaScript, även om detta faktiskt inte bildar en enhetlig helhet. TypeScripts typ-system är avsiktligt inte strikt, för att möjliggöra att utvecklare kan vara produktiva. Strikthet är att föredra framför brist på strikthet om allt annat är lika, men utvecklares produktivitet är ibland viktigare. (Se TypeScript Design Non-Goal #3.)

TypeScript behandlar vanligtvis objekttyper som öppna/utbyggbara och inte stängda/förseglade/exakta. Så värdet {a: "x", b: "y"} kan tilldelas {a: string} trots dess extra b-egenskap. Men sådana saker är också orsaker till fel, så TypeScript har kontroller för överflödiga egenskaper och in-operatorns begränsning.


För syftet med in-operatorn skulle jag säga att den mentala modellen är följande kognitiva dissonans: om du använder in för att kontrollera en egenskap som finns i vissa medlemmar av en union, kommer TypeScript att behandla dessa unionstyper som slutna/täta/exakta och eliminera de andra medlemmarna (även om detta tekniskt sett är osäkert). Om du däremot använder in för att kontrollera en egenskap som inte finns i någon unionmedlem, då behandlar den typen som öppen/utbyggbar och lägger helt enkelt till en egenskap av typen unknown till den objektstypen.

Således, när du skriver

const test = (union: UnionBoth): void => {
    if ('name' in union && 'age' in union) {
        union.name
        union.age
    }
}

vad som händer är: först kontrollerar du 'name' in union vilket begränsar union från UnionBoth till {name: string}, eliminerande {age: number}. Sedan kontrollerar du 'age' in union vilket begränsar union från {name: string} till {name: string, age: unknown}. Detta är helt i linje med den ovan nämnda mentala modellen, vilket leder till den ärligt talat bisarra situationen här. Om du byter ordning på dina in-tester, får du motsatt bisarr sak.

Enligt min åsikt visar detta att: nästan ingen använder unionstyper på det sätt du gör nu. Om de kontrollerar egenskaper med in, är det för att de försöker diskriminera en union, även om det är osäkert att göra det. Om du verkligen vill göra denna typ av kontroll, måste du omstrukturera din unionstyp för att vara tydlig med hur saker fungerar:

type UnionBoth = Name | Age | (Name & Age)

const test = (union: UnionBoth): void => {
    if ('name' in union && 'age' in union) {
        union;
        // ^? Name & Age
        union.name
        //    ^? string
        union.age
        //    ^? number
    }
}

Nu nämns möjligheten för Name & Age uttryckligen, och TypeScript använder sin ursprungliga in-avgränsning för att avgränsa till Name & Age, och använder aldrig den nya "narrowing för olistade egenskaper" eftersom det alltid finns en union-medlem som innehåller nyckeln du testar för.

Playground-länk till kod