For the past two months I've been participating in a batch at the Recurse Center in Brooklyn, a "writer's retreat for programmers." I've been having lots of fun learning about Interpreters, Programming Languages and Neural Nets, but you apply to RC with a project in mind, and mine was to contribute to the TypeScript open source project. I've used TypeScript and written about it for years, but I've never contributed code to it. Time to change that!
The result is PR #57465, which adds a feature I've always wanted in TypeScript: inference of type predicates. I'll have more to say about that PR in a future post. But for now I'd like to share some of what I've learned about type predicates while implementing it.
What are type predicates?
What is a type predicate? Whenever a function in TypeScript returns a boolean
, you can change it to return a "type predicate" instead:
|
Here x is number
is the type predicate. Any function that returns a type predicate is a "user-defined type guard."
Here's how you use a type guard:
|
In this case there's little advantage over doing the typeof
check directly in the if
statement. But type guards really shine in two specific circumstances:
- When TypeScript can't infer the type you want on its own.
- When you pass the type guard as a callback.
The former often comes up with input validation:
|
But in this post we're more interested in the latter. Here's the motivating scenario:
|
We've filtered the array of strings and numbers down to just the numbers, but TypeScript hasn't been able to follow along. The result is a spurious type error.
Changing from an arrow function to the type guard fixes the problem:
|
This works because the declaration of Array.prototype.filter
has been overloaded to work with type predicates. Several built-in Array
methods work this way, including find
and every
:
|
What if you return false?
If a function returns x is T
, then it's clear what it means when it returns true
: x
is a T
! But what does it mean if it returns false
?
TypeScript's expectation is that type guards return true
if and only if the predicate is true. To spell it out:
- If the type guard returns
true
thenx
isT
. - If the type guard returns
false
thenx
is notT
.
This often works so intuitively that you don't even think about it. Using our isNumber
type guard, for example:
|
But it can definitely go wrong! What about this type guard?
|
If this returns true
then x
is definitely a number
. But if it returns false
, then x
could be either a string
or a large number
. This is not an "if and only if" relationship. This sort of incorrect type predicate can lead to unsoundness:
|
This passes the type checker but blows up at runtime:
|
This highlights two important facts about type guards:
- TypeScript does very little to check that they're valid.
- There are expectations around the
false
case, and getting it right matters!
Generally functions that combine checks with &&
should not be type guards because the type will come out incorrectly for the false
case.
Many functions only care about the true
case. If you're just passing your type guard to filter
or find
, then you won't get into trouble. But if you pass it to a function like lodash's _.partition
then you will:
|
This is an unsound type and it will lead to trouble. It's interesting to compare this with inlining the check into an if
statement:
|
Left to its own devices, TypeScript gets this right. The only reason it went wrong before was because we fed it bad information: isSmallNumber
should not have been a type predicate!
Because of the strict rules around what false
means, a type guard cannot, in general, replace an if
statement. There's a proposal to fix this by adding "one-sided" or "fine-grained" type guards. If it were adopted, you'd be able to declare something like this:
|
A test for valid type predicates
In the last example, we could tell that the type predicate was invalid because inlining it into an if
statement produced different types in the else
block than calling the type guard did.
This feels like a good test for type guards! Does it work?
As it turns out, no! There's a subtlety around subtyping that hadn't occurred to me until the tests failed on my PR branch. The details and solution are a little too in the weeds for this post. But when I write a post about the making of this PR, we'll cover it in depth. There is a test. Check out the PR if you're curious.
In the meantime, though, we can talk about a few heuristics. If a condition fails the "inlining" test, then it's definitely not a valid type predicate.
Non-Nullishness, not Truthiness
JavaScript and TypeScript make a distinction between "truthiness" and "non-nullishness":
|
This is important for types like number
and string
. Here's why:
|
The interesting part is the number
in the else
block. The number 0
is falsy, so numOrNull
can be a number
in the false case. (In theory TypeScript could narrow it to 0 | null
, but the TS team has decided this is not worth it.)
This means that if you make isTruthy
return a type predicate, functions like partition
will produce unsound types:
|
TypeScript thinks that nulls
is an array of null
values, but it could actually contain numbers (specifically zeroes). This is an unsound type. It's also likely to be a logic error: do you really mean to filter out the zeroes? If you're calculating an average, this will give you an incorrect result.
Better to use isNonNullish
or the equivalent. This is safe:
|
You can make the generic isNonNullish
into a type predicate, too:
|
This relies on the {}
type, which is TypeScript for "all values except null
and undefined
." This is one of the few good uses of this very broad type!
Composing predicates
In general you can compose type predicates with "or":
|
Similarly, you can compose predicates with "and" if their types intersect:
|
This could happen if you have a big discriminated union and you have helpers that match different subsets of it.
Be careful about composing conditions that can't be fully represented in the type system, however. You can't define a TypeScript type for "numbers less than 10" or "strings less than ten characters long" or "numbers other than zero." So conditions like these generally don't belong in a type guard:
|
Conclusions
When you write a user-defined type guard, it's easy to only think about the true
case: if you write x is string
and you know that x
must be a string
when the function returns true
, then surely you're good to go, right?
As this post has explained, that's only half the battle. In order for a type guard to be completely safe, it's also important to know what the type of the parameter is when it returns false
. This is the hidden side of type predicates. It's easy to get wrong, and this can lead to unsound types.
Because it might be used in an if
/ else
statement or with functions like _.partition
, you want your type guard to be bulletproof! Make sure you provide the "if and only if" semantics that TypeScript expects.