typescripttypeguardsnarrowing

In TypeScript, how can I narrow a type through a series of filters?


I want to apply a chain of filters to an array and narrow the type accordingly. The array elements belong to a union type with some shared properties.

type TypeA = {name: string; id: string; createdAt: Date};
type TypeB = {name: string; id: string; metaData: Record<string, unknown>};
type TypeC = {id: string};
type TypeD = {name: string; createdAt: Date};
// etc.

type MyUnionType = TypeA | TypeB | TypeC | TypeD;

const hasName = (x: MyUnionType): x is MyUnionType & {name: string} => 'name' in x;
const hasId = (x: MyUnionType): x is MyUnionType & {id: string} => 'id' in x;

function foo(ary: MyUnionType[]) {
    ary.filter(hasName)
       .filter(hasId)
       .map(x => x.name + x.id);  // ❌ Error because it thinks property `id` does not exist
}

I've thought of two workarounds:

  1. Write a specific filter for the combination I need:
function hasNameAndId(x: MyUnionType): x is MyUnionType & {name: string} {
    return 'name' in x && 'id' in x;
}

This solution isn't scalable, as it means writing a function for each combination of filters.

  1. Instead of using named filter functions, write the filters inline with the type information:
function foo(ary: MyUnionType[]) {
    ary.filter((x): x is MyUnionType & {name: string} => 'name' in x)
       .filter((x: MyUnionType & {name: string}): x is MyUnionType & {name: string; id: string} => 'id' in x)
       .map(x => x.name + x.id);
}

This solution gets messy very quickly.


Solution

  • When you call type guard functions directly, the compiler performs the sort of narrowing you're looking for automatically:

    function foo(ary: MyUnionType[]) {
      ary.flatMap(x => hasName(x) && hasId(x) ? x.name + x.id : []) 
    }
    

    But in order for Array.filter() to work as a type guard function you need to pass in a callback that exactly matches the relevant call signature:

    interface Array<T> {
      map<U>(cb: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    }
    

    And unfortunately that's not what's happening after you call ary.filter(hasName).filter(hasId), where the array element is of type MyUnionType & { name: string }, but your callback takes an argument of type MyUnionType. Those aren't seen as compatible enough, so the type guard is skipped and you just get the "normal" filter() behavior where the output type doesn't change.


    The most straightforward way around this, I think, is to make your type guard functions generic so that the call to the second filter() can instantiate the generic type parameter accordingly. Something like this:

    const hasName = <T extends MyUnionType>(x: T): x is T & { name: string } => 'name' in x;
    const hasId = <T extends MyUnionType>(x: T): x is T & { id: string } => 'id' in x;
    

    And then the second filter() call works; T will be instantiated with MyUnionType & {name: string}, and the return type is an array of (MyUnionType & {name: string}) & {id: string}), as desired:

    function foo(ary: MyUnionType[]) {
      ary.filter(hasName)
        .filter(hasId)
        .map(x => x.name + x.id); // okay
    }
    

    Playground link to code