I have encountered an interesting behaviour of TS type guards and would like to ask you whether I just have to accept it or whether I am doing something wrong. I put together minimal example (well, close to minimal).
enum LegType {
LegA = 'LegA',
LegB = 'LegB',
}
interface LegA {
type: LegType.LegA
foo: string
}
interface LegB {
type: LegType.LegB
bar: string
}
type Leg = LegA | LegB
enum PlanType {
T = 'T',
C = 'C',
}
interface TPlan {
type: PlanType.T
legs: LegA[]
}
interface CPlan {
type: PlanType.C
legs: (LegA | LegB)[]
}
type Plan = TPlan | CPlan
const isLegA = (o: Leg): o is LegA => o.type === LegType.LegA
const getFoo = (plan: Plan): string | undefined => {
// const legs: Leg[] = plan.legs // With this line the code builds with no errors.
const legs = plan.legs // With this line the code contains error, the type of `legs` is resolved as: LegA[] | (LegA | LegB)[]
const someLegA = legs.find(isLegA)
return someLegA?.foo // Property 'foo' does not exist on type 'LegA | LegB'.
}
Why is adding :Leg[]
necessary? Is it possible to rewrite the code so that TS understands itself what the correct type is?
Yuck, I see what's happening and I think the only reasonable answer here is to do what you did: annotate legs
as Leg[]
.
So, the library type signature for Array
's find()
method is overloaded and the overload that accepts a user-defined type guard callback and returns a narrowed element type is generic:
find<S extends T>( predicate: (this: void, value: T, index: number, obj: T[]) => value is S, thisArg?: any ): S | undefined; find(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined;
When you don't annotate legs
, it gets the union type Array<Leg> | Array<LegA>
. This type is considered compatible with Array<Leg>
but it won't treat it as such automatically (which is probably good. An Array<LegA>
should not let you push()
a LegB
onto it, but an Array<Leg>
should).
That means that the type of legs.find
is itself a union of functions whose signatures are not identical:
type LegsFind = {
<S extends LegA>(p: (this: void, v: LegA, i: number, o: LegA[]) => v is S, t?: any): S | undefined;
(p: (v: LegA, i: number, o: LegA[]) => unknown, t?: any): LegA | undefined;
} | {
<S extends Leg>(p: (this: void, v: Leg, i: number, o: Leg[]) => v is S, t?: any): S | undefined;
(p: (v: Leg, i: number, o: Leg[]) => unknown, t?: any): Leg | undefined;
};
Prior to TypeScript 3.3 the compiler would just give up and tell you it doesn't know how to call a union of functions like this. And someone would tell you to change Array<X> | Array<Y>
to Array<X | Y>
if you're calling non-mutating methods like find()
, and you'd use Leg[]
and move on.
TypeScript 3.3 introduced improved behavior for calling union types. There are cases when the compiler can take a union of functions and represent it as a single function which takes an intersection of the parameters from each member of the union. You can read more about it in the relevant pull request, microsoft/TypeScript#29011.
But, there are cases when it can't do this: specifically if multiple legs of the union are overloaded or generic functions. Unfortunately for find()
, that's the case you're in. It looks like the compiler tries to figure out a way to make this callable, but it gives up on the generic signatures corresponding to user-defined-type-guard narrowing and ends up with just the "regular" one, probably because they are compatible with each other:
type LegsFind33 = (p: (v: LegA, i: number, o: Leg[]) => unknown, thisArg?: any) => Leg | undefined;
So you call it successfully, no narrowing takes place, and you run into the problem here. The issue of calling unions of functions is not completely solved; its GitHub issue microsoft/TypeScript#7294 is closed and labeled "Revisit".
So until that is revisited, the advice here is still to treat Array<X> | Array<Y>
likeArray<X | Y>
if you're calling non-mutating methods like find()
... use Leg[]
and move on.
Okay, hope that helps; good luck!