Search code examples
typescripttype-inference

Inferred type predicates not working on filtering objects if type is explicitly set


Can someone explain why inferred type predicates don't work on an array of objects if the type is explicitly set?

According to this announcement, from version 5.5 on, TypeScript is able to infer the new type when an array is filtered.

For example:

const arr0  = [ 1, 2, 3, undefined, 4, 5, undefined];
//     ^? const arr0: (number | undefined)[]
const numsOnly0 = arr0.filter(n => typeof n === "number");
//     ^? const numsOnly0: number[]

In this simple case, it also works if the array is explicitly typed:

type NumOrUndefined = number | undefined;
const arr1:NumOrUndefined[]  = [ 1, 2, 3, undefined, 4, 5, undefined];
//     ^? const arr1: NumOrUndefined[]
const numsOnly1 = arr1.filter(n => typeof n === "number");
//     ^? const numsOnly0: number[]

TypeScript knows, that after filtering, the previous type NumOrUndefined[] isn't narrow enough and infers number[].

I was excited to see this new feature to work in an actual codebase. What I found is that this inferring doesn't seem to work for objects which are explicitly typed.

For example:

type OriginalType = { id: number | undefined; name: string | undefined }
const objArr1: OriginalType[] = [
//      ^? const objArr1: OriginalType[]
    {
        id: 1,
        name: "foo"
    },
    {
        id: undefined,
        name: "foo"
    },
    {
        id: 3,
        name: undefined
    },
    {
        id: undefined,
        name: undefined
    },
]

// type is not inferred
const definedOnly1 = objArr1.filter(obj => typeof obj.id === "number" && typeof obj.name === "string");
//      ^? const definedOnly1: OriginalType[]

Without the explicit typing, it does work:

const objArr0= [
//     ^? objArr0: (({id: number; name: string;} | {id: undefined; name: string;} | {id: number;    name: undefined;} | {id: undefined; name: undefined;})[]
    {
        id: 1,
        name: "foo"
    },
    {
        id: undefined,
        name: "foo"
    },
    {
        id: 3,
        name: undefined
    },
    {
        id: undefined,
        name: undefined
    },
]

// new type is inferred properly
const definedOnly0 = objArr0.filter(obj => typeof obj.id === "number" && typeof obj.name === "string");
//     ^? definedOnly0: {id: number; name: string;}[]

One way of "helping" the TypeScript compiler seems to be using satisfies instead of explicit typing. Although I find it not very convenient since this does not work for e.g. typed function parameters. But just for the sake of completness, here's the example:

const objArr2 = [
//    ^? same as objArr0
    {
        id: 1,
        name: "foo"
    },
    {
        id: undefined,
        name: "foo"
    },
    {
        id: 3,
        name: undefined
    },
    {
        id: undefined,
        name: undefined
    },
] satisfies OriginalType[];

// original type is used on original object, but also inferred on filtered object properly
const definedOnly2 = objArr2.filter(obj => typeof obj.id === "number" && typeof obj.name === "string");
//      ^? same as definedOnly0

Can anyone explain if this is the intended behavior and why?

Playground


Solution

  • There's no problem here with inferred type predicates; the problem is that TypeScript does not narrow the apparent type of an object when you narrow one of its properties (unless that object type is a discriminated union and the property you're narrowing is a discriminant property). This is a missing feature of TypeScript, requested at microsoft/TypeScript#42384.

    So if you start with an object of type { id: number | undefined; name: string | undefined } and check its id and name property, the properties will be narrowed, but the object itself will stubbornly stay wide:

    type OriginalType = { id: number | undefined; name: string | undefined }
    
    function foo(o: OriginalType) {
        if ((typeof o.id === "number") && (typeof o.name === "string")) {
            let narrowed: { id: number, name: string };
            narrowed = { id: o.id, name: o.name }; // okay, properties of o narrowed
            narrowed = o; // error, o was not narrowed!
        }
    }
    

    Thus the check of the two properties isn't seen as a type guard on the object at all, and therefore there's no type predicate to infer.


    On the other hand, when you break your Original type into a union by distributing the property unions over the whole type:

    type UnionType =
        { id: number, name: string } | { id: number, name: undefined } |
        { id: undefined, name: string } | { id: undefined, name: undefined }
    

    then this union is seen as a discriminated union (because undefined is a valid discriminant, see the TypeScript 3.2 release notes), and the property checks result in object narrowing:

    function bar(o: UnionType) {
        if ((typeof o.id === "number") && (typeof o.name === "string")) {
            let narrowed: { id: number, name: string };
            narrowed = { id: o.id, name: o.name }; // okay, properties of o narrowed
            narrowed = o; // okay, o also narrowed
        }
    }
    

    So that's the explanation. If you want to see this changed, it wouldn't hurt for you to give microsoft/TypeScript#42384 a 👍. It probably wouldn't help much either, but if enough people do it then the TypeScript team might see the demand as high enough to be worth investigating.

    Playground link to code