Search code examples
typescriptdiscriminated-union

Discriminated Union of Generic type


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.


Solution

  • 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;
      }
    };