user15163984
user15163984

Reputation: 534

The modified closure variable ts cannot be detected

The online demo

I modified the flag variable but TS didn't find it. Is this a bug?

function fn () {
  let flag = true

  function f () {
    // modify flag
    flag = false
  }

  for (let i = 0; i < 10; i++) {
    // error: This condition will always return 'false' since the types 'true' and 'false' have no overlap.
    if (flag === false) { break }
    f()
  }

  return flag
};

fn()


Upvotes: 4

Views: 407

Answers (1)

jcalz
jcalz

Reputation: 328788

See microsoft/TypeScript#9998 for an authoritative answer to this question.

The behavior you're seeing is not a bug, but a design limitation in TypeScript which really can't be addressed very easily. You may have noticed that this version of fn() works fine:

function fn() {
  let flag = true
  for (let i = 0; i < 10; i++) {
    if (flag === false) { break }
    flag = false;
  }
  return flag
};

Here the compiler infers that flag is of type boolean, which in TS is a shorthand equivalent of the union true | false. When you assign true to it upon initialization, the compiler narrows the apparent type of flag from true | false to just true. After this line, the compiler assumes that flag is definitely true. Inside the for loop scope, the compiler sees another assignment flag = false and knows that it is possible to enter the loop body with flag being either true or false, and so the narrowing to true is essentially reset to boolean inside the loop. This general behavior is called control flow analysis, where the compiler analyzes the order in which code is evaluated so it can give more specific types to certain values in certain scopes.


Unfortunately, as you note, this breaks when you refactor that flag = false line into the body of f(). The compiler does not reset the narrowing after f() is called, and the apparent type of flag is still true in the entire loop. And therefore it erroneously complains that the check flag === false is useless because it thinks it will always return false.

But why does the compiler not realize that calling f() has an effect on the closed-over flag variable? The answer is that you just can't perform control flow analysis with closure mutations in a way which is both accurate and performant. You can't perform inlining for every function call, re-analyzing the implementation of every function for each call to it. This "wouldn't be even remotely practical", according to the TS team dev lead (in ms/TS#9998, linked above). Possibly it could be done for a toy example like in this question, but for any real world code base you would end up having prohibitively long compile times... maybe even impossibly long, since control flow analysis through function calls is almost like simulating the program being run for every possible set of inputs.

So accuracy is not feasible. Instead, the compiler must make optimizing assumptions, which will turn out to be incorrect in some cases. TypeScript currently makes the "optimistic" assumption that function calls have no effect on state. This behaves well in a lot of circumstances, and it certainly saves compilation time. But if function calls do affect state, then bad things happen, as you see here

The opposite, "pessimistic" assumption is that function calls may affect all state, and that every function call should reset all narrowings. This would be much worse in most situations, since harmless function calls are incredibly more common than state-changing ones.

It is possible that something better could be done here, but for now, this is the way it is. TypeScript just isn't equipped to track state mutations inside of function calls.


So that's the answer to the question as asked.

But if you want to know how to prevent this erroneous error and you can't easily refactor to avoid state-changing functions, the easiest way is to prevent control flow narrowing of flag entirely. You can do this by changing the initial assignment of flag so that the compiler does not realize that it's a literal true value:

let flag = true as boolean;

By writing true as boolean you are widening the type of the true value to true | false, and the compiler now has no idea what the initial state of flag is. Therefore at every point in the rest of the scope, flag is either true or false according to the compiler. And so checking flag === false is accepted.

Playground link to code

Upvotes: 6

Related Questions