Search code examples
typescriptasynchronousgeneratortype-narrowing

Why shouldn't TypeScript widen types following await or yield?


With strictness enabled, we can see that both generators and asynchronous functions will leave narrowed types narrow following an await/yield statement, resulting in inaccurate typing.

let value: null | string = null;

function* generatorExample() {
  value = null;
  console.log(`before: ${value}`); // type = null, value = null. OK
  yield;
  console.log(`after: ${value}`); // type = null, but value may be a string. INCORRECT
}

async function promiseExample() {
  value = null;
  console.log(`before: ${value}`); // type = null, value = null. OK
  await new Promise((resolve) => setTimeout(resolve));
  console.log(`after: ${value}`); // type = null, but value may be a string. INCORRECT
}

TypeScript Playground Link

The type of value inside each function is string | null, until they explicitly set it to null, at which point it is narrowed correctly to null. However after a yield or await statement, it may again be a null or string, so I would expect TypeScript's code flow analysis to widen the type back up. It doesn't, and as a result the type does not correctly describe the actual runtime type.

I was going to submit this as a bug, but I notice that it's been consistently this way on every version I've checked. I wanted to kick the tires here first and ask if there's a good reason why TypeScript shouldn't widen variables following await/yield?


Solution

  • This problem is not related specifcally to generator functions or await. It is rather a design limitation which affects the whole language. Even a simpler example containing only a function will show this issue.

    let value: null | string = null;
    
    function fn() {
      value = null
    
      console.log(`before: ${value}`); // type = null, value = null. OK
      [0].forEach(() => value = "abc")
      console.log(`after: ${value}`); // type = null, but value may be a string. INCORRECT
    }
    

    This issue has been discussed in detail at #9998 which describes the trade-offs the compiler has to make regarding control flow analysis. There is no concept in TypeScript which can detect possible side effects of function calls, or as your question shows, await calls or even yield.

    The TypeScript team has decided to use an optimistic approach where any narrowing efforts of control flow analisys continue to persist, even if possible side effects may have invalidated the narrowing. For now, this is the desired behaviour. A pessimistic approach may lead to all kinds of annoyances and problems where one would have to repeatedly narrow down the types because any function calls would widen the type back.

    Unless there are any major changes to the language like const parameters or const functions where side effects are not allowed or even volatile variables which signal to the compiler that the value may change without notice, we will have to live with the possible unsoundness introduced by this optimistic narrowing approach.