Follow

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use
Contact

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

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.

MEDevel.com: Open-source for Healthcare and Education

Collecting and validating open-source software for healthcare, education, enterprise, development, medical imaging, medical records, and digital pathology.

Visit Medevel

>Solution :

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.

Add a comment

Leave a Reply

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use

Discover more from Dev solutions

Subscribe now to keep reading and get access to the full archive.

Continue reading