Typescript: Typvakt med stränglitertaltyp
Om jag har följande kod:
interface A {
foo: string
}
interface B extends A {
foo: "bar"
}
interface C extends A {
foo: "baz"
}
interface D ...
nämligen, ett gränssnitt och ett antal andra gränssnitt som utökar det ursprungliga, men fixerar en specifik egenskap hos det ursprungliga till ett enda strängliteral, är det möjligt för mig att skapa en typvakt för gränssnitt som utökar det ursprungliga genom att jämföra mot denna egenskap på ett generiskt sätt? Något i stil med följande:
function isExtensionOfA<T extends A>(obj: A): obj is T {
return (obj.foo === T.foo.literal_value)
}
Svar #1
TypeScripts statiska typsystem tas bort när TypeScript kompileras till JavaScript, så den generiska typen T kommer inte att existera vid körning. Det betyder att det inte finns någon "T.foo" att tala om som du kan kontrollera vid körning. Om du vill kontrollera ett värde vid körning måste du faktiskt tillhandahålla ett sådant värde.
Här är ett möjligt tillvägagångssätt, där du uttryckligen skickar in foo-värdet som du kontrollerar mot:
function isExtensionOfA<T extends A>(
obj: A,
foo: T["foo"]
): obj is T {
return (obj.foo === foo);
}
Och du kan se att det "fungerar":
function test(obj: A) {
if (isExtensionOfA<B>(obj, "bar")) {
obj.bProp.toFixed() // okay
} else if (isExtensionOfA<C>(obj, "baz")) {
obj.cProp.toUpperCase() // okay
} else if (isExtensionOfA<D>(obj, "qux")) {
obj.dProp // okay
}
}
där citattecknen är på grund av följande stora problem med detta tillvägagångssätt:
test({ foo: "bar" }); // okay at compile time, but:
// 💥 RUNTIME ERROR! obj.bProp is undefined
Du kan se att {foo: "bar"} accepterades av test() även om det inte är en B. Det beror på att typer i TypeScript är strukturella (baserade på datas form) och inte nominella (baserade på namn eller deklaration). När du skriver T extends A betyder det "T har en form som är kompatibel med A", så allt du vet är att det har en foo-egenskap av typen string. Det betyder inte "T är en av de gränssnitt som jag uttryckligen deklarerade som utvidgande A". Så T kan vara alla möjliga typer utöver B, C eller D, inklusive anonyma typer som typen av objektliteralen {foo: "bar"}.
Hoppsan.
Om du verkligen vill begränsa något till en viss uppräknad uppsättning av gränssnitt, måste du definiera denna typ uttryckligen som en union:
type ExplicitA = B | C | D // | ...
Och när du har det kan du skriva isExtensionOfA() så att den begränsar sitt indata till ExplicitA-objekt:
function isExtensionOfA<K extends ExplicitA["foo"]>(
obj: ExplicitA,
foo: K
): obj is Extract<ExplicitA, { foo: K }> {
return (obj.foo === foo);
}
och nu använder jag Extract-hjälpverktygstypen för att filtrera ExplicitA till bara den unionmedlem som motsvarar det foo-värde som skickats in. Nu kan vi låta vår test()-funktion använda det utan att ens behöva ange typargumenten manuellt:
function test(obj: ExplicitA) {
if (isExtensionOfA(obj, "bar")) {
obj.bProp.toFixed() // okay
} else if (isExtensionOfA(obj, "baz")) {
obj.cProp.toUpperCase() // okay
} else if (isExtensionOfA(obj, "qux")) {
obj.dProp // okay
}
}
Och nu kommer kompilatorn att klaga på att man anropar test() med någon anonym typ som råkar vara kompatibel med A:
test({ foo: "bar" }); // error at compile time
Så, det är säkert, och allt "fungerar". Oj, skräckcitat igen.
Problemet är att vi nu har gått igenom mycket extra arbete för att göra något som TypeScript gör nativt. Typen ExplicitA är en diskriminerad union där foo är diskriminant-egenskapen. Vi behöver inte en separat isExtensionOfA() [användardefinierad typvaktfunktion] (https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates). Vi kan bara kontrollera diskriminant-egenskapen direkt:
function test(obj: ExplicitA) {
if (obj.foo === "bar") {
obj.bProp.toFixed() // okay
} else if (obj.foo === "baz") {
obj.cProp.toUpperCase() // okay
} else {
obj.dProp // okay
}
}
Så du vill antagligen bara använda en diskriminerad union till att börja med. Det innebär att ditt A-gränssnitt kanske inte är nödvändigt (om det inte finns andra bas-egenskaper du vill ärva), och du kan byta namn på A till unionen:
interface B {
foo: "bar"
bProp: number;
}
interface C {
foo: "baz"
cProp: string;
}
interface D {
foo: "qux"
dProp: boolean
}
type A = B | C | D // | ...
function test(obj: A) {
if (obj.foo === "bar") {
obj.bProp.toFixed() // okay
} else if (obj.foo === "baz") {
obj.cProp.toUpperCase() // okay
} else {
obj.dProp // okay
}
}
Och nu saker verkligen
Playground-länk till kod
