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