Home > Mobile >  Type-narrowed `let` variable reverts to its original type when passed to a closure
Type-narrowed `let` variable reverts to its original type when passed to a closure

Time:07-20

In the following code snippet, why does foobar revert to being of type Foobar when it is referenced from the function passed to filter?

type Foo = { type: "foo"; foo: number };
type Bar = { type: "bar"; bar: number };
type Foobar = Foo | Bar;

const foobars: Foobar[] = [
  { type: "foo", foo: 42 },
  { type: "bar", bar: 43 },
];

const numbers = [40, 41, 42, 43, 44];

function logFoo(foo: Foo) {
  console.log(foo.foo);
}

for (let foobar of foobars) {
  if (foobar.type === "foo") {
    console.log(foobar.foo); // fb is Foo
    logFoo(foobar); // OK
    console.log(numbers.filter(x => x < foobar.foo)); // Property 'foo' does not exist on type 'Foobar'
  }
}

If you change let foobar of foobars to const foobar of foobars the type error goes away.

CodePudding user response:

If you're in a hurry, the answer is to use const.

for (const foobar of foobars) {
  if (foobar.type === "foo") {
    console.log(foobar.foo); // fb is Foo
    logFoo(foobar); // OK
    console.log(numbers.filter(x => x < foobar.foo)); // Property 'foo' does not exist on type 'Foobar'
  }
}

A const (or 'final', as Java calls it) variable will never be reassigned. That gives us some very strong guarantees. Inside the if statement, we know absolutely that the value foobar has type Foo. And since it will never be reassigned, we also know that inside of any closures within the if statement. You get the behavior you're expecting.

Now consider let.

for (let foobar of foobars) {
  if (foobar.type === "foo") {
    console.log(foobar.foo); // fb is Foo
    logFoo(foobar); // OK
    console.log(numbers.filter(x => x < foobar.foo)); // Property 'foo' does not exist on type 'Foobar'
  }
}

A let-bound variable can change. TypeScript is smart enough to see that, inside the if statement, it hasn't changed. But when it's passed to a closure, that closure could be called from anywhere else in the code. If someone were to come along and assign a new value to this variable somewhere unrelated in the code, then the closure's type safety would break. So the closure makes the coarsest and safest guarantee it can, which is that the let-bound variable is of its original type.

In this particular example, it's theoretically possible that TypeScript could reason about the fact that our foobar is never assigned to at all. After all, its scope is limited to the inside of a for loop, and there are no such assignment statements in there. But rather than get that far into the weeds (the scope of a variable could be an entire file, after all, or in the case of instance variables could be multiple files), TypeScript decides to just be conservative.

Note that if you do intend to modify the variable later but want to capture its particular value at a moment, rather than the variable itself, you can always make a new const-bound variable to do so.

for (let foobar of foobars) {
  if (foobar.type === "foo") {
    const newFoobar = foobar;
    console.log(numbers.filter(x => x < newFoobar.foo)); // Property 'foo' does not exist on type 'Foobar'
  }
}

At the moment newFoobar is created, foobar has type Foo. And newFoobar is a const, so it will always have type Foo. Nothing can change that fact beyond this point, so it's safe to capture newFoobar (as type Foo) inside of a closure.

CodePudding user response:

Silvio's answer is correct but does not fix your code as you might want to use Bar in your loop (as mentionned, using const will result in foobar being a Foo). Therefore i would recommand to use a forEach (or a for in using let) on foobars like

foobars.forEach((foobar: Foobar) => {
    if (foobar.type === "foo") {
      const myFoo: Foo = <Foo> foobar;
      logFoo(myFoo); // OK
      console.log(numbers.filter(x => x < myFoo.foo));
    } else if (foobar.type === "bar") {
      const myBar: Bar = <Bar> foobar;
      console.log(numbers.filter(x => x > myBar.bar));
    }
});
  • Related