David Sharp

Typescript type narrowing and closures

I came across an interesting nuance while working with Typescript one day related to type narrowing and closures.

At first glance it would appear that the following code should work. I have a variable data that is of type string | undefined, I then check that the variable is defined. Thanks to Typescript's type narrowing, the data variable on line 4 would typically be seen as a string, but in this case that is not so.

let data: string | undefined = "HI"

if (data) {
  doSomething(() => data.toLowerCase()) // error: Object is possibly 'undefined'.
}

function doSomething(func: () => string) {
  console.log(func())
}

At first I thought about creating an issue on the Typescript github project, but after further inspection, I realized that the Typescript compiler was correct (of course).

I was able to create a more obvious example of the problem using setTimeout function. Even though we've confirmed the data variable has a value, because we are using it in a an anonymous function, the capturing closure knows that when the doSomething function runs, there is no guarantee that the data variable is still defined.

let data: string | undefined = "HI"

if (data) {
  doSomething(() => data.toLowerCase()) // error: Object is possibly 'undefined'.
}

// updated doSomething function with timeout
function doSomething(func: () => string) {
  setTimeout(() => console.log(func()), 5_000)
}

Now we have modified the code to ignore the "possibly undefined" error by using the ! assert defined symbol, and then adding data = undefined directly after the doSomething function call. There are now no compiler errors, but running this code will throw a Cannot read property 'toLowerCase' of undefined runtime error after 5 seconds.

let data: string | undefined = "HI"

if (data) {
  doSomething(() => data!.toLowerCase()) // added '!'
  data = undefined // set to undefined
}

function doSomething(func: () => string) {
  setTimeout(() => console.log(func()), 5_000)
}

One way to handle this error is to save our data variable to a new variable temp, which the Typescript type narrower at that time knows will always be a string, and thus fixes all compiler and runtime errors.

let data: string | undefined = "HI"

if (data) {
  const temp = data
  doSomething(() => temp.toLowerCase())
  data = undefined
}

function doSomething(func: () => string) {
  setTimeout(() => console.log(func()), 5_000)
}