Avoiding "catch-all" else branches with type trickery
Niklas Gruhn
Posted on October 11, 2023
"Catch-all" else branches can lead to unintended behavior when extra cases are silently introduced. We can leverage the type checker to catch such issues before going to production.
For example, here we have a TShirt
type and a function, that returns the length of a TShirt
in centimeters:
type TShirt = {
size: 'small' | 'medium' | 'large'
}
function lengthCM(tshirt : TShirt) : number {
if (tshirt.size === 'small') {
return 70
} else if (tshirt.size === 'medium') {
return 72
} else {
return 74
}
}
The final else branch is such a "catch-all" else branch. It handles the remaining case where size
is "large"
. If we later add the size "xlarge"
and forget to update the function, then we return the same length for both "large"
and "xlarge"
.
It's better to handle each case explicitly:
// vvvvvv type error here
function lengthCM(tshirt : TShirt) : number {
if (tshirt.size === 'small') {
return 70
} else if (tshirt.size === 'medium') {
return 72
} else if (tshirt.size === 'large') {
return 74
}
// implicit:
// return undefined
}
However, without any else branch we get an implicit return undefined
at the end of our function. This causes a type error because the effective return type is now number | undefined
instead of number
.
The implicit return undefined
should actually be unreachable because we exhaustively checked all T-shirt sizes. So a simple fix would be to throw an error:
function lengthCM(tshirt : TShirt) : number {
if (tshirt.size === 'small') {
return 70
} else if (tshirt.size === 'medium') {
return 72
} else if (tshirt.size === 'large') {
return 74
}
throw new Error('this should be unreachable')
}
Remember, when we introduce the size "xlarge"
, the throw statement becomes reachable. And because this is a runtime error it might be overlooked until the code is already in production. If possible it's always better to raise a type error, because type errors are detected during development.
To do that we introduce this weird little helper function:
function unreachable(witness : never) : never {
return witness
}
It's actually impossible to call this function without shushing the type checker, because to call it, you have to provide an argument of type never
. never
is "the empty type". There is no value that has type never
. Except in the middle of unreachable code:
function lengthCM(tshirt : TShirt) : number {
if (tshirt.size === 'small') {
return 70
} else if (tshirt.size === 'medium') {
return 72
} else if (tshirt.size === 'large') {
return 74
}
unreachable(tshirt.size)
}
Here tshirt.size
has type never
because there is no value left that it could have at this point. The fact that we have access to a variable of type never
is evidence that we are in the middle of unreachable code. So calling unreachable
type-checks. BUT if the code ever becomes reachable (when we introduce "xlarge"
) we get a type error that reminds us to fix lengthCM
before deploying to production:
function lengthCM(tshirt : TShirt) : number {
if (tshirt.size === 'small') {
return 70
} else if (tshirt.size === 'medium') {
return 72
} else if (tshirt.size === 'large') {
return 74
} else if (tshirt.size === 'xlarge') {
return 76
}
unreachable(tshirt.size)
}
Technically it is possible to always call unreachable
by maliciously type casting to never
:
unreachable("totally a string" as never)
So in practice (and also for readability) I suggest this final implementation:
function unreachable(witness : never) : never {
throw new Error('this should be unreachable')
}
Posted on October 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.