Typeguards and Closures in TypeScript

Discover a tricky gotcha when using typeguards in TypeScript.

Posted: September 1, 2022
Find more posts about:

A closure is when a function calls a variable that is not included in its own scope. For example:

const name = 'kbravh'

const logName = () => {
  console.log(name)
}

If the logName() function is passed around anywhere else, it will still have access to the name variable, even though it's out of scope! JavaScript does us the favor of making sure the function always has access to it.

When we use this closure pattern, though, TypeScript alerts us to a potential issue. If TypeScript cannot ensure that a variable won't change in the closure, it will show a warning. Take the following example:

type Settings = {
  username: string,
  nickname?: string
}

const settings: Settings = {
  username: 'kbravh',
  nickname: 'coolbro'
}

We define a Settings type with an optional property nickname, then we set up a settings object that uses that field. Next, we define a function showNickname that requires a string as a parameter.

const showNickname = (nickname: string) => {
  console.log(nickname);
}

In order to be able to call our function without problems, we then set up a typeguard to make sure that nickname is defined. So when we call showNickname, we don't have any issues!

if (!settings.nickname) {
  throw new Error('Nickname necessary!')
}

showNickname(settings.nickname);

Instead of immediately showing the user's nickname though, let's only show it once the user has clicked somewhere. So we'll set up an event listener that calls our function and passes in the nickname.

if (!settings.nickname) {
  throw new Error('Nickname necessary!')
}

window.addEventListener('click', () => showNickname(settings.nickname))
// ❌ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

Uh oh! TypeScript isn't happy about that and gives us a type error, saying that the nickname we're passing in might be undefined. But how? We set up our typeguard!

Though it's not immediately apparent, TypeScript is actually correct here. TypeScript is letting you know that even though nickname is defined now, it might not be by the time the closure is actually executed. Before the event listener fires, there might be a line of code that sets nickname back to undefined.

if (!settings.nickname) {
  throw new Error('Nickname necessary!')
}

window.addEventListener('click', () => showNickname(settings.nickname))
// ❌ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

settings.nickname = undefined

We need to make sure that the code is safe and that nickname is defined when we need it. There are a couple of options for doing so!

Solutions

Move the typeguard

If we move our typeguard into the closure, we can make sure that it's called right before the function executes.

window.addEventListener('click', () => {
  if (!settings.nickname) {
    throw new Error('Nickname necessary!')
  }
  showNickname(settings.nickname)
  // No TypeScript error! 👍
})

We could also move the typeguard into showNickname function itself, or even provide a default value.

const showNickname = (nickname?: string) => {
  if (!nickname) {
    throw new Error('Nickname necessary!')
  }
  console.log(nickname)
}

// or...

const showNickname = (nickname: string = 'Pal') => {
  console.log(nickname)
}

Extract nickname into a const

If we can let TypeScript know that the value won't change, it will be able to rest easy.

// Extract nickname into its own const
const nickname = settings.nickname

if (!nickname) {
  throw new Error('Nickname necessary!')
}

window.addEventListener('click', () => showNickname(settings.nickname))
// No TypeScript error! 👍

"But wait!" You might exclaim, "wasn't our settings object defined with const? Why didn't that work?"

Whenever we instantiate an object with const, it just lets TypeScript know that the entire variable cannot be reassigned. The object's properties can still be changed, though!

const settings: Settings = {
  username: 'kbravh'
}

settings = {
  username: 'newuser'
}
// ❌ Cannot assign to 'settings' because it is a constant.

settings.username = 'newuser'
// No TypeScript error

Unfortunately, using Object.freeze() to lock our object properties does not resolve the TypeScript error.

const settings: Settings = {
  username: 'kbravh',
  nickname: 'coolbro'
}

Object.freeze(settings)

// or...

const settings = Object.freeze<Settings>({
  username: 'kbravh',
  nickname: 'coolbro'
})

window.addEventListener('click', () => showNickname(settings.nickname))
// ❌ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.