问题
I'd like to be able to use union discrimination with a generic. However, it doesn't seem to be working:
Example Code (view on typescript playground):
interface Foo{
type: 'foo';
fooProp: string
}
interface Bar{
type: 'bar'
barProp: number
}
interface GenericThing<T> {
item: T;
}
let func = (genericThing: GenericThing<Foo | Bar>) => {
if (genericThing.item.type === 'foo') {
genericThing.item.fooProp; // this works, but type of genericThing is still GenericThing<Foo | Bar>
let fooThing = genericThing;
fooThing.item.fooProp; //error!
}
}
I was hoping that typescript would recognize that since I discriminated on the generic item
property, that genericThing
must be GenericThing<Foo>
.
I'm guess this just isn't supported?
Also, kinda weird that after straight assignment, it fooThing.item
loses it's discrimination.
回答1:
The problem
Type narrowing in discriminated unions is subject to several restrictions:
No unwrapping of generics
Firstly, if the type is generic, the generic will not be unwrapped to narrow a type: narrowing needs a union to work. So, for example this does not work:
let func = (genericThing: GenericThing<'foo' | 'bar'>) => {
switch (genericThing.item) {
case 'foo':
genericThing; // still GenericThing<'foo' | 'bar'>
break;
case 'bar':
genericThing; // still GenericThing<'foo' | 'bar'>
break;
}
}
While this does:
let func = (genericThing: GenericThing<'foo'> | GenericThing<'bar'>) => {
switch (genericThing.item) {
case 'foo':
genericThing; // now GenericThing<'foo'> !
break;
case 'bar':
genericThing; // now GenericThing<'bar'> !
break;
}
}
I suspect unwrapping a generic type that has a union type argument would cause all sorts of strange corner cases that the compiler team can't resolve in a satisfactory way.
No narrowing by nested properties
Even if we have a union of types, no narrowing will occur if we test on a nested property. A field type may be narrowed based on the test, but the root object will not be narrowed:
let func = (genericThing: GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>) => {
switch (genericThing.item.type) {
case 'foo':
genericThing; // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)
genericThing.item // but this is { type: 'foo' } !
break;
case 'bar':
genericThing; // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)
genericThing.item // but this is { type: 'bar' } !
break;
}
}
The solution
The solution is to use a custom type guard. We can make a pretty generic version of the type guard that would work for any type parameter that has a type
field. Unfortunately, we can't make it for any generic type since it will be tied to GenericThing
:
function isOfType<T extends { type: any }, TValue extends string>(
genericThing: GenericThing<T>,
type: TValue
): genericThing is GenericThing<Extract<T, { type: TValue }>> {
return genericThing.item.type === type;
}
let func = (genericThing: GenericThing<Foo | Bar>) => {
if (isOfType(genericThing, "foo")) {
genericThing.item.fooProp;
let fooThing = genericThing;
fooThing.item.fooProp;
}
};
回答2:
It's a good point that the expression genericThing.item
is seen as a Foo
inside the if
block. I thought that it works only after extracting it to a variable (const item = genericThing.item
). Probably a better behaviour of latest versions of TS.
This enables the pattern matching like in the function area
in the official documentation on Discriminated Unions and that is actually missing in C# (in v7, a default
case is still necessary in a switch
statement like this).
Indeed, the weird thing is that genericThing
is still seen undiscriminated (as a GenericThing<Foo | Bar>
instead of GenericThing<Foo>
), even inside the if
block where item
is a Foo
! Then the error with fooThing.item.fooProp;
does not surprise me.
I guess the TypeScript team has still some improvements to do to support this situation.
来源:https://stackoverflow.com/questions/50870423/discriminated-union-of-generic-type