Search code examples
typescripttypestype-narrowing

Typescript: Get type "after" nested type narrow


I want to extract a type that is the result of a type narrow. I have a type Parent that has a property x which in turn has two properties a and b, of which at least one must be set. I've modeled this using unions:

type A = {
    type: "A",
    a: string
}

type B = {
    type: "B",
    b: string
}

type AB = {
    type: "AB",
    a: string
    b: string
}

type Parent = {
    x: A | B | AB
    // potentially many more properties
}

const ps : Parent[] = []

Type-narrowing works fine using the type discriminator.

ps.map(p => {
  if(p.x.type == "AB") {
    p.x.a // works!
  }
})

In some places of my code I only work with parent objects that have both properties a and b. E.g. by using a filter.

const asAndBs = ps.filter((p) : p is AAndBParent => p.x.type == "AB")

For that I need to have an explicit reference to the narrowed type AAndBParent which would be defined as type Parent where Parent.x.type == "AB". The only way I could think of was to create a new type "from scratch". This is cumbersome if Parent has many more properties, which would have to be synced between the type definitions.

type AAndBParent = {
    x: AB
    // would have to copy all other properties from Parent
}

Question 1: Can I define AAndBPanret in relation to Parent?

Question 2: Can I define the filter function in a type-safe manner?

I could have written the type predicate wrongly but the compiler wouldn't complain. The type-narrowing-logic of the compiler does know better.

const asAndBs = ps.filter((p) : p is AAndBParent => p.x.type != "AB")
asAndBs[0].x.a // runtime error: potentially undefined

Solution

  • Can I define AAndBParent in relation to Parent?

    Yes — to derive a new type from an existing type and narrow one or more properties at the same time, you can use an intersection with a type having the narrowed properties, like this:

    TS Playground

    type Parent = {
      x: A | B | AB;
      // Potentially many more properties, for example:
      isParent: true;
    };
    
    type AAndBParent = Parent & { x: AB };
    
    declare const ab: AAndBParent;
    
    ab.x
     //^? (property) x: AB
    
    ab.isParent
     //^? (property) isParent: true
    

    Can I define the filter function in a type-safe manner?

    Yes!

    However, creating type guard functions can be a bit of a craft — they exist to allow you to aid the compiler in places where the built-in control flow analysis can't practically provide the narrowing that you expect — and they are conceptually related to type assertions in that they're mechanisms for you to provide more information than the compiler can.

    TypeScript will prevent you from making blatant errors like this:

    declare const ps: Parent[];
    
    ps.filter((p): p is AAndBParent => p.x.oops === "AB"); /* Error
                                           ~~~~
    Property 'oops' does not exist on type 'A | B | AB'.
      Property 'oops' does not exist on type 'A'.(2339) */
    

    but you are responsible for ensuring that the runtime validation performed in the function body aligns with the type in the predicate.

    In your case, you are only validating that the object is of a shape { x: { type: "AB" } }, so just use that in the predicate and let the compiler infer the rest — it will work as expected:

    const asAndBs = ps.filter(
      (p): p is typeof p & { x: { type: "AB" } } => p.x.type === "AB"
    );
    
    const [first] = asAndBs;
    
    if (first) {
      first.isParent
          //^? (property) isParent: true
    
      first.x.type
            //^? (property) type: "AB"
    
      first.x.a
            //^? (property) a: string
    
      first.x.b
            //^? (property) b: string
    }
    

    Code in TS Playground