typescripttype-narrowing

"Cannot invoke an object which is possibly 'undefined'" even after ensuring it !== undefined


Why do I get a Cannot invoke an object which is possibly 'undefined' Typescript error even after I check that the func reference is not undefined?

type Hoge = {
    func?: (str: string) => boolean
}

const myFunc = (obj: Hoge) => {
    const data = ['AAA', 'BBB', 'CCC']

    if(obj.func !== undefined) {
        data.filter(obj.func) // ok
        data.filter(v => obj.func(v)) // ng Cannot invoke an object which is possibly 'undefined'.
    }
}

Solution

  • short answer

    Control flow analysis is complicated, and Typescript's analysis only goes so far. In this case, it easily proves that on the //ok line, data.func !== undefined. But it is not so easy to prove data.func's value will not change before it is invoked sometime in the future within the closure that is passed to data.filter.

    See the solution at the end of this answer.

    long answer

    Type narrowing is achieved by using control flow analysis to prove that a reference on a particular line has a narrower type than its originally declared or previously known type.

    For the line

    data.filter(obj.func) // ok
    

    the control flow analysis is trivial; obj.func is dereferenced immediately after it was checked to be !== undefined.

    But in the next line

    data.filter(v => obj.func(v))
    

    obj.func is NOT dereferenced immediately. It only appears on the next line lexically. But in fact, it won't be invoked until later, "inside" the execution of data.filter. Typescript would have to recursively do control flow analysis down into the implementation of data.filter. Obviously it does not in this case. Maybe a future version of Typescript will (they keep improving it). Or maybe it's too complex or expensive. Or maybe it's impossible?

    🟣 Help me improve this answer

    Does Javascript's "single threaded architecture" mean that no other thread can change the value of obj.func before data.filter is finished?

    see for yourself

    Put the following code in your IDE or try it in the Typescript Playground. Observe the types of a, b, c, d and e. Notice how c, which lexically appears between b and d, has a different type. This is because wrappingFunc does not actually execute between b and d. The type of c cannot be not narrowed simply because it appears lexically within the if clause. Notice how the value of obj.func is modified before wrappingFunc is called:

    type Hoge = {
        func?: (str: string) => boolean
    }
    
    const myFunc = (obj: Hoge) => {
        const data = ['AAA', 'BBB', 'CCC']
    
        const a = obj.func           // ((str: string) => boolean) | undefined
    
        if(obj.func !== undefined) {
            const b = obj.func       // (str: string) => boolean
    
            const wrappingFunc = function () {
                const c = obj.func   // ((str: string) => boolean) | undefined
                c()                  // ERROR
            }
    
            const d = obj.func       // (str: string) => boolean
    
            obj.func = undefined     // modify obj.func before calling wrappingFunc
            wrappingFunc()           // this call will fail; Typescript catches this possibility above
       }
    
        const e = obj.func           // ((str: string) => boolean) | undefined
    }
    
    

    three solutions

    One way to fix the error is to use a type assertion, which is basically saying to Typescript: "You may not know the type, but I do, so trust me.":

    const myFunc = (obj: Hoge) => {
        const data = ['AAA', 'BBB', 'CCC']
    
        if(obj.func !== undefined) {
            data.filter(obj.func) // ok
            data.filter(v => (obj.func as (str: string) => boolean)(v) ) 
        }
    }
    

    Or you can make the same type assertion more simply using Typescript's ! assertion operator as a apokryfos suggests.

    The way I prefer is to assign the value of obj.func to a variable in the closure that Typescript can easily prove is never modified:

    const myFunc = (obj: Hoge) => {
        const data = ['AAA', 'BBB', 'CCC']
    
        if(obj.func !== undefined) {
            data.filter(obj.func) // ok
            const filterFunc = obj.func // filterFunc is const in the closure
            data.filter(v => filterFunc(v)) // ok
        }
    }