Discriminated Union of Generic type
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;
}
};
As @Titian explained the problem arises when what you really need is:
GenericThing<'foo'> | GenericThing<'bar'>
but you have something defined as:
GenericThing<'foo' | 'bar'>
Clearly if you only have two choices like this you can just expand it out yourself, but of course that isn't scalable.
Let's say I have a recursive tree with nodes. This is a simplification:
// different types of nodes
export type NodeType = 'section' | 'page' | 'text' | 'image' | ....;
// node with children
export type OutlineNode<T extends NodeType> = AllowedOutlineNodeTypes> =
{
type: T,
data: NodeDataType[T], // definition not shown
children: OutlineNode<NodeType>[]
}
The types represented by OutlineNode<...>
need to be a discriminated union, which they are because of the type: T
property.
Let's say we have an instance of a node and we iterate through the children:
const node: OutlineNode<'page'> = ....;
node.children.forEach(child =>
{
// check a property that is unique for each possible child type
if (child.type == 'section')
{
// we want child.data to be NodeDataType['section']
// it isn't!
}
})
Clearly in this case I don't want to define children with all possible node types.
An alternative is to 'explode out' the NodeType
where we define children. Unfortunately I couldn't find a way to make this generic because I can't extract out the type name.
Instead you can do the following:
// create type to take 'section' | 'image' and return OutlineNode<'section'> | OutlineNode<'image'>
type ConvertToUnion<T> = T[keyof T];
type OutlineNodeTypeUnion<T extends NodeType> = ConvertToUnion<{ [key in T]: OutlineNode<key> }>;
Then the definition of children
changes to become:
children: OutlineNodeTypeUnion<NodeType>[]
Now when you iterate through the children it's an expanded out definition of all possibilities and the descriminated union type guarding kicks in by itself.
Why not just use a typeguard? 1) You don't really need to. 2) If using something like an angular template you don't want a bazillion calls to your typeguard. This way does in fact allow automatic type narrowing within *ngIf
but unfortunately not ngSwitch