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