Search code examples
typescript

Why doesn't .filter() narrow items correctly when checking on an union typed property?


With TypeScript 5.5 .filter() has become better at narrowing the output type, however it doesn't seem to narrow correctly when checking on a union typed property of an object.

Consider the following two examples (TS Playground):

const example1 = [{ foo: 123 }, { foo: null }]
    .filter(item => {
        //   ^? (parameter) item: { foo: number; } | { foo:…
        return item.foo != null;
    });
const thisIsOK = example1;
//       ^? const thisIsOK: { foo: number; }[]

const example2 = [{ foo: 123 }, { foo: null }]
    .map(({ foo }) => ({ foo }))
    .filter(item => {
        //   ^? (parameter) item: { foo: number | null; }
        return item.foo != null;
    });
const notOK = example2;
//      ^? const notOK: { foo: number | null; }[]

Notice how in the first example each item has a different type (discriminated union?). In the second example however we created a new object in a .map() which "merged" the union to one type and "moved" it into the foo property. Now .filter() no longer works. Why not and what is the solution?


Solution

  • The problem is that, in general, narrowing the apparent type of an object's property doesn't serve to narrow the apparent type of the object itself, unless the object is of a discriminated union type and the property is a discriminant property. So when you check the foo property of {foo: number | null} it doesn't narrow the object, whereas if you check the foo property of {foo: number} | {foo: null} it does (because null is a unit type and therefore can serve as a discriminant).

    There's a longstanding open feature request at microsoft/TypeScript#42384 to change this behavior. If that ever gets implemented, then (item: {foo: number | null}) => item.foo != null will automatically be inferred as returning a type predicate and your code will just work.

    Until and unless that happens, you need to work around it.


    The general workaround is to build a custom type guard function that narrows parent objects by properties, such as:

    function narrowParent<T extends object, K extends keyof T, U extends T[K]>(
        o: T, k: K, guard: (x: T[K]) => x is U
    ): o is Extract<{ [P in keyof T]: P extends K ? U : T[P] }, T> {
        return guard(o[k])
    }
    

    which takes an object o of type T, one of its known keys k of type K, and a guard callback that can narrow from the type of o[k] to some narrower type U, and which narrows o itself to a type that looks like T except for the K property is narrowed. For example:

    const x = {
        bar: "abc",
        baz: Math.random() < 0.5 ? 123 : undefined,
        qux: new Date()
    }
    // const x: { bar: string; baz: number | undefined; qux: Date; } 
    if (narrowParent(x, "baz", x => x !== undefined)) {
        // const x: { bar: string; baz: number; qux: Date; } 
    }
    

    Here we check x.baz and it narrows x itself.

    So armed with that, you can rewrite it as

    const example2 = [{ foo: 123 }, { foo: null }]
        .map(({ foo }) => ({ foo }))
        .filter(item => narrowParent(item, "foo", x => x != null));
    const nowOK = example2;
    //      ^? const nowOK: { foo: number; }[]
    

    Of course for any particular example you might not want the full general solution. You might decide that to just skip the whole "map-then-filter" approach in favor of a flatMap() approach:

    const example3 = [{ foo: 123 }, { foo: null }]
        .flatMap(({ foo }) => (foo != null ? [{ foo }] : []));
    // const example3: { foo: number; }[]
    

    Playground link to code