24

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.

NSjonas
  • 7,200
  • 5
  • 42
  • 78
  • What error do you get on that last line? Does extracting just the item from the genericThing, either at the top of the function or by destructuring in the arguments, make any difference? – jonrsharpe Jun 15 '18 at 07:28
  • @jonrsharpe open the typescript playground link and you can see it. `fooProp does not exist on type ...` – NSjonas Jun 16 '18 at 20:16

2 Answers2

23

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;
  }
};
nickf
  • 499,078
  • 194
  • 614
  • 709
Titian Cernicova-Dragomir
  • 157,784
  • 15
  • 245
  • 242
  • oh man... that solution is rough! Good to know it's possible though. My solution is just to code against the nested object instead of trying to accept the narrowed generic. EG: having my function accept `Foo` (instead of `GenericThing` directly if I need to do something discriminated with it – NSjonas Jun 18 '18 at 17:12
  • @NSjonas that is also an option :-) – Titian Cernicova-Dragomir Jun 18 '18 at 17:45
0

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.

Romain Deneau
  • 2,046
  • 6
  • 17