Home > Software engineering >  why sometimes need to do type narrowing again inside a child function scope
why sometimes need to do type narrowing again inside a child function scope

Time:03-10

why sometimes need to do type narrowing again inside a child function scope.

I write three functions in the example below:

interface Obj {
  func?: Function;
}

const arr = [1, 2, 3];

function test1 (obj: Obj){
  if (obj.func !== void 0){
    arr.forEach(key => {
      obj.func(key);   // error, TS2722: Cannot invoke an object which is possibly 'undefined'.
    });
  }
}

function test2 (obj: Obj){
  if (obj.func !== void 0){
    arr.forEach(key => {
      if (obj.func !== void 0) {  // do narrowing again
        obj.func(key);   // ok
      }
    });
  }
}

function test3 (func?: Function){
  if (func !== void 0){
    arr.forEach(key => {
      func(key);   // ok, no second time narrowing
    });
  }
}

As you see, in function test1, we do a type narrowing in this function's body, but do not do it again in the forEach's callback function that is another function scope, and we see an error. And then i write function test2 but do the type narrowing again in the callback child scope of forEach, and it turns to ok.

Then in the function test3, i changed the parameter from an object to an optional parameter with the type of function. This is not the same with test1, the compiler do not ask me to do type narrowing again in the function scope of forEach's callback.

Can somebody explain why in test1 i need to narrow the type again? And why in the test1 and test3 the compiler has different behavior?

CodePudding user response:

Because Typescript doesn't know that the callback function is executed synchronously.

This is basically the same thing:

let foo: string | null = 'abc'

if (foo) {
  setTimeout(() => foo.toUpperCase(), 1000) // Object is possibly 'null'.(2531)
}
foo = Math.random() > 0.5 ? 'def' : null

Here the callback function actually gets executed later, any amount of code have have run by then that changes the value.

Because of this narrowing is only effective in a scope that Typescript can guarantee runs synchronously. And with a callback function, it cannot guarantee that.

The only type information we have about forEach is that it accepts a function as an argument. When that function is actually called is not part of the type at all.


In your example:

function test1 (obj: Obj){
  if (obj.func !== void 0){
    arr.forEach(key => {
      obj.func(key);   // error, TS2722: Cannot invoke an object which is possibly 'undefined'.
    });
  }
}

Here, in theory, some other code in some other file, that has a reference to the passed obj could set the func prop to something different between the time the the prop is checked, and the time the callback function is actually run. This does run completely totally synchronously, but again, Typescript has no way of knowing that.


function test3 (func?: Function){
  if (func !== void 0){
    arr.forEach(key => {
      func(key);   // ok, no second time narrowing
    });
  }
}

This one works because the func value can't be mutated from outside the function. You are passing a function or undefined, and no matter when the when the callback functions run, that value is never going to change.


This would also work:

function test4 (obj: Obj){
  const func = obj.func
  if (func){
    arr.forEach(key => {
      func(key); // fine
    });
  }
}

Here we store the func property locally in the function. This way if something changed obj elsewhere it doesn't matter since we have a reference to the func from before it changed.


Lastly, this is only a problem with functions. Simple for loops Typescript can guarantee are synchronous:

function test5 (obj: Obj){
  if (obj.func){
    for (const key of arr) {
      obj.func(key); // fine
    }
  }
}

Playground

  • Related