Search code examples
typescripttype-inferenceunion-typesdiscriminated-union

Typescript infers type never on Union of two types


In the following example, in the getAdditionalData function, Typescript infers the type of value to never after validating the type through isBarType. I would expect the type to be FooEntity since Entity can be either FooEntity OR BarEntity.

Why is that the case?

I'm aware I can avoid the problematic by doing the opposite validation, i.e. if(isFooType(value)) { return value.additionalData;} but I'm interested to know why Typescript behaves like this.

Link to TS Playground

type BaseEntity = {
    id: string,
    name: string,
}

type FooEntity = BaseEntity & {
    additionalData: string,
}

type BarEntity = BaseEntity & {};

type Entity = FooEntity | BarEntity;

function isFooType(value: Entity): value is FooEntity {
    return value.name === 'foo';
}

function isBarType(value: Entity): value is BarEntity {
    return value.name === 'bar';
}

function getAdditionalData(value: Entity): string {
    if(isBarType(value)) {
        return '';
    }

    // Property 'additionalData' does not exist on type 'never'
    return value.additionalData;
}

Solution

  • type BaseEntity = {
        id: string,
        name: string,
    }
    
    type FooEntity = BaseEntity & {
        additionalData: string,
    }
    
    type BarEntity = BaseEntity & {};
    
    type Entity = FooEntity | BarEntity;
    

    Let's expand this to the resulting types. This is what Entity resolves to.

    type Entity =
      | { // FooEntity
          id: string;
          name: string;
          additionalData: string;
        }
      | { // BarEntity
          id: string;
          name: string;
        };
    

    Here FooEntity is assignable to a BarEntity because FooEntity has every property that is required by BarEntity. This means that both sides of the union are valid BarEntity types.

    So when you do:

        if(isBarType(value)) {
            return '';
        }
    

    You are saying, if this value is a bar type, then early return. But all members of the union are valid BarEntity types. And if it's not a BarEntity then it cannot be a FooEntity, since all FooEntity's are also BarEntity's.

    So all parts of the union that match BarEntity are excluded, which is all members of the union since they all match, and so the result is that that it narrows to never.


    Put another way:

    type FooEntity = {
        a: boolean,
    }
    
    type BarEntity = {
        a: boolean
        b: boolean
    }
    
    type Entity = FooEntity | BarEntity;
    
    type MyNarrowedEntity = Exclude<Entity, { a: boolean }> // never
    

    Here we exclude an object type that all union members match to, which elminates all members of that union, and the result is never.

    This is basically what the type is narrowing doing for you.


    You probably want a discriminated union:

    type BaseEntity = {
        id: string,
        name: string,
    }
    
    type FooEntity = BaseEntity & {
        name: 'foo',
        additionalData: string,
    }
    
    type BarEntity = BaseEntity & {
        name: 'bar',
    };
    
    type Entity = FooEntity | BarEntity;
    

    Now BarEntity is not assignable to FooEntity because the name is different. Then your code works as expected.

    See playground