Search code examples
typescripttypeguards

TS type guards with complex type


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

TS playground

Why is adding :Leg[] necessary? Is it possible to rewrite the code so that TS understands itself what the correct type is?


Solution

  • 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!