typescriptasynchronousasync-awaittype-narrowing

Narrowing related behaviours on callbacks in TypeScript when using let


In TypeScript, there are two narrowing related behaviours that are both intentional, one of them being that narrowing is not respected in callbacks, my question being about this one specifically.

function fn(obj: { name: string | number }) {
  if (typeof obj.name === "string") {
    // Errors
    window.setTimeout(() => console.log(obj.name.toLowerCase());
  }
}

These lines of code are on their Github. I've understood the thing that a value could change types between when the narrowing occurred and when the callback was invoked. But I don't understand the case where you only have a let declared variable (not a property on an object).

For example, I have this code from the TypeScript playground.

const needsString = function(parameter: string) {
  console.log(parameter);
}

function doSomething () {
  let str: string | undefined;
  const obj: { property?: string } = { property: 'abc' };

  if (obj.property) {
    const foo = async () => {
      needsString(obj.property); // NOT OK
    }
  }

  if (str) {
    const foo = async () => {
      needsString(str); // OK
    }
  }
}

My question is, why the same rule from above doesn't apply for the let variables because they can be changed too. For me, the compiler says ok when I am trying to do that.

Another strange example, is when the let variable is in the global scope.

let str: string | undefined;

if (str) {
  const foo = async () => {
    needsString(str); // NOT OK
  }

  const bar = () => {
    needsString(str); // NOT OK
  }
}

In this example, it says not ok for neither async nor sync function.


Solution

  • In general it is difficult or impossible for TypeScript to track control flow that crosses function boundaries. By and large, any narrowing that happens inside a function will be lost outside the function, and vice versa. In particular, narrowings of variables are generally lost inside closures. This is discussed at length in microsoft/TypeScript#9998. That's why most of the code in your examples has errors.

    But TypeScript 5.4 introduced preserved narrowing in closures following last assignments for let variables as implemented in microsoft/TypeScript#56908. That means the following code triggers an error for TypeScript 5.3 and below, but not for TypeScript 5.4 and above:

    function doSomething () {
      let str: string | undefined; 
      str = "abc";   
      if (str) {
        const foo = async () => {
          needsString(str); // okay in TS5.4+, error in TS5.3-
        }
      }
    }
    

    The above-linked pull request explains how and why it works the way it does. Essentially: if it is easy for TypeScript to figure out when the last assignment to a let variable occurred, then any narrowings after that point will persist in closures created after that last assignment. So if you assign a value to str after the closure is created, then the narrowing resets and you get an error:

    function doSomething () {
      let str: string | undefined;    
      str = "abc";
      if (str) {
        const foo = async () => {
          needsString(str); // error in all TS versions
        }
      }
      str = "def"; // added this
    }
    

    Furthermore, if the let variable is declared in global scope, then narrowings cannot persist in closures. As mentioned in microsoft/TypeScript#56908, "we don't preserve type refinements for mutable global variables in closures since such globals might be modified by code in other files or modules."

    Playground link to code