Narrowing Type Predicates
This aricle explains a methodology for improving the type safety of TypeScript type predicates.
Type Predicates
Type predicates in TS are functions whose return value determines whether a parameter value is of a specified type or not.
const predicate = (value: A): value is B => value.type === 'B';
The type being tested for must always be a subtype of the parameter type: B extends A
.
The type predicate returns true if the parameter value is of type B and false if it's some other subtype of A.
Motivation
Type predicates are by design not type safe. They allow a developer to discriminate between values based on arbitrary checks whose safety developers must ensure on their own. However, there's another use case for type predicates: abstraction. Often a type predicate is logic that narrows the type of a value, extracted to a function. The checks made in the type predicate narrow the type in such a way it would be type safe if the checks were inlined. In these cases a developer need not necessarily define a type predicate but because type narrowing within a function scope is not propagated to the calling function's scope the function must be defined as a type predicate to maintain type narrowing in the calling function's scope. Developers are faced with two bad choices: define a type-unsafe type predicate or inline type checks every time.
Example Case
The motivation is best explained with an example. Consider the following type predicate.
type Image = {
id: string;
type: 'IMAGE';
imageUrl: string;
size: [number, number];
}
type Video = {
id: string;
type: 'VIDEO';
thumbnailUrl: string;
videoUrl: string;
size: [number, number];
}
type ShortFormVideo = {
id: string;
type: 'SHORT_FORM_VIDEO';
videoUrl: string;
}
type Media = Image | Video | ShortFormVideo;
type VideoMedia = Video | ShortFormVideo;
const isVideoMedia = (media: Media): media is VideoMedia => {
switch (media.type) {
case 'VIDEO':
case 'SHORT_FORM_VIDEO':
return true;
default:
return false;
}
}
The type predicate isVideoMedia
is a reusable function, handy for discriminating between media objects that define a video and any others.
Its use makes code more declarative and readable than inlining the type check everywhere it's needed.
However, the type-unsafe nature of a type predicate can cause issues.
Let's define a new type of video media:
type UltraWideVideo = {
id: string;
type: 'ULTRA_WIDE_VIDEO';
thumbnailUrl: string;
videoUrl: string;
size: [number, number];
}
type Media = Image | Video | ShortFormVideo | UltraWideVideo;
type VideoMedia = Video | ShortFormVideo | UltraWideVideo;
Notice this change in the types makes the implementation of isVideoMedia
no longer correct.
It's possible a developer may miss this issue, especially if the definitions of the types and the type predicate are not in the same file.
There won't be any errors raised by TS either.
Type Constraints
Let's look at how the type predicate could have been defined as a narrowing type predicate to ensure TS raises an error if the implementation is wrong.
const isVideoMedia = (media: Media): media is VideoMedia => {
switch (media.type) {
case 'VIDEO':
case 'SHORT_FORM_VIDEO':
return (media satisfies VideoMedia, true);
default:
return (media satisfies Exclude<Media, VideoMedia>, false);
}
}
Each return value expression also includes a voided value constrained by a type. With this implementation, after adding the new media type TS will raise an error because one of the constraints is not met.
Definition
A narrowing type predicate is a type predicate whose every return value is a sequence expression where the first element contains a type constraint on the parameter value and the last element is a boolean literal. The type constraint is the type implied in the return type annotation or its complement depending on the return value.
const predicate = (value: A): value is B => value.type === 'B'
? (value satisfies B, true)
: (value satisfies Exclude<A, B>, false);
The syntax is unfortunately verbose but still readable. The type constraints are defined such that at least one of them will be violated unless type narrowing infers exactly the types the type predicate implies. In other words the type unsafe type predicate matches exactly the type safe constraints, making the whole type safe.
Any type predicate can be transformed into a narrowing type predicate algorithmically.
-
Replace each return value that's not a boolean literal with a ternary
expression ? true : false
-
Replace each return value
true
with(value satisfies B, true)
-
Replace each return value
false
with(value satisfies Exclude<A, B>, false)
Further Safety With Static Analysis
The obvious flaw with this approach is that changing the return type of a type predicate will involve changing each return statement's type constraint too. When making changes a developer might fail to change the type constraints along with the return type and TS won't raise an error if the return type doesn't match the type constraint. To prevent mistakes like this and to make changing return types easier I wrote the linter rule chamion-typescript/narrowing-type-predicates. The rule identifies type predicates adhering to the narrowing type predicate pattern and reports any mismatched type constraints. It also automatically updates the type constraints for you if you let it.
Combining the lint rule with the narrowing type predicate pattern allows you to write type safe type predicates which isn't possible in TS otherwise.
When Not To Use It
Not all type predicates should be narrowing. Sometimes a type predicate is type unsafe on purpose. Any type check leveraging functionality that isn't type safe in its implementation will have to incorporate an element of "trust me, bro". Narrowing type predicates are meant only for cases where the predicate abstracts some type checks you'd otherwise inline.