Reputation: 152
I'm trying to make Typescript infer the correct object in a switch case and I'm struggling to understand why my scenario does not work (with an object) but does work as expected when it's a union type.
From the playground link:
interface Base {
id: number;
name: "a" | "b" | "c" | "d"; // This is type-guarding correctly
type: {
id: number;
name: "a" | "b" | "c" | "d"; // This is NOT type-guarding correctly
}
}
interface A extends Base {
name: "a";
type: {
id: number;
name: "a";
}
a: {
b: number;
}
}
interface B extends Base {
name: "b";
type: {
id: number;
name: "b";
}
b: {
a: number;
}
}
interface Generic extends Base {
name: "c" | "d";
type: {
id: number;
name: "c" | "d";
}
}
type C = A | B | Generic
function test(t: C) {
// If you switch the switch case to 't.name'
switch(t.type.name) {
// switch(t.name) {
case "a":
// This will be properly type-guarded as it will infer A
console.log(t.a)
break;
case "c":
console.log(t.name);
break;
case "b":
// This will be properly type-guarded as it will infer B
console.log(t.b);
break;
}
}
I have one object that can have different properties depending of the value of type.name
. I want to, in a switch case, to check for that type.name
and have typescript understanding which properties I have access based on that.
As per the example, if I do the switch case on name
, it works as expected.
Besides using as
or flattening the object, what other options do I have?
Upvotes: 2
Views: 1126
Reputation: 327964
TypeScript does not support "nested" discriminated unions where you use a subproperty to discriminate among members. See microsoft/TypeScript#18758. So while C
is a discriminated union with name
as the discriminant, you can't use type.name
as the discriminant. So you pretty much can't use any of the machinery designed to handle discriminated unions, such as using a switch
on the discriminant property.
If you want to get the compiler to type guard on subproperties, so that you don't have to use type assertions or change your object structure, then the only option I can imagine is to write a user-defined type guard function that performs the narrowing for you. It could look like this:
function hasNestedTypeName<
T extends { type: { name: string } },
K extends string
>(t: T, k: K): t is
T extends { type: { name: infer N } }
? K extends N ? T : never
: never {
return t.type.name === k
}
So if you call hasNestedTypeName(t, k)
and it returns true
, then the compiler will narrow t
to be just those union members whose type.name
property can accept k
as a value. We have to refactor test
to use if
/else
chains instead of switch
because of the boolean
return type of type guard functions. Like this:
function test(t: C) {
if (hasNestedTypeName(t, "a")) {
// function hasNestedTypeName<C, "a">(t: C, k: "a"): t is A
t // A
console.log(t.a)
} else if (hasNestedTypeName(t, "c")) {
// function hasNestedTypeName<B | Generic, "c">(t: B | Generic, k: "c"): t is Generic
t // Generic
console.log(t.name)
} else if (hasNestedTypeName(t, "b")) {
t // B
console.log(t.b)
}
This works the same way as your version, more or less (it's actually hard to get the hasNestedTypeName(t, "c")
to work as desired, since if it returns false
, the compiler assumes that t
is not a Generic
, but that's not necessarily the case. I'd stay away from discriminated unions where the discriminant is itself a union, since it could do weird things).
Whether or not it's worth such a change depends on the use case. Personally I'd suggest that refactoring the object shape so that the discriminant is at the top level is the best way to go, since it uses the language's strengths instead of trying to work around its weaknesses.
Upvotes: 3