Typescript Narrowing
Discriminated unions
Most of the examples we've looked at so far have focused on narrowing single variables with simple types like string
,
boolean
, and number
. While this is common, most of the time in JavaScript we'll be dealing with slightly more
complex structures.
Let's imagine we're trying to encode shapes like circles and squares for some motivation. Circles keep track of their
radiuses and squares keep track of their side lengths. We'll use a field called kind
to tell which shape we're dealing
with. Here's a first attempt at defining Shape
.
interface Shape {
kind: 'circle' | 'square';
radius?: number;
sideLength?: number;
}
We can write a getArea
function that applies the right logic based on if it's dealing with a circle or square. We'll
first try dealing with circles.
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// 'shape.radius' is possibly 'undefined'.
}
Under strictNullChecks
that gives us an error - which is
appropriate since radius
might not be defined. But what if we perform the appropriate checks on the kind
property?
function getArea(shape: Shape) {
if (shape.kind === 'circle') {
return Math.PI * shape.radius ** 2;
// 'shape.radius' is possibly 'undefined'.
}
}
Hmm, TypeScript still doesn't know what to do here. We've hit a point where we know more about our values than the type
checker does. We could try to use a non-null assertion (a !
after shape.radius
) to say that radius
is definitely
present.
function getArea(shape: Shape) {
if (shape.kind === 'circle') {
return Math.PI * shape.radius! ** 2;
}
}
But this doesn't feel ideal. We had to shout a bit at the type-checker with those non-null assertions (!
) to convince
it that shape.radius
was defined, but those assertions are error-prone if we start to move code around. Additionally,
outside of strictNullChecks
we're able to accidentally
access any of those fields anyway (since optional properties are just assumed to always be present when reading them).
We can definitely do better.
The problem with this encoding of Shape
is that the type-checker doesn't have any way to know whether or not radius
or sideLength
are present based on the kind
property. We need to communicate what we know to the type checker.
With that in mind, let's take another swing at defining Shape
.
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
sideLength: number;
}
type Shape = Circle | Square;
Here, we've properly separated Shape
out into two types with different values for the kind
property, but radius
and sideLength
are declared as required properties in their respective types.
Let's see what happens here when we try to access the radius
of a Shape
.
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// Property 'radius' does not exist on type 'Square'.
}
Like with our first definition of Shape
, this is still an error. When radius
was optional, we got an error (with
strictNullChecks
enabled) because TypeScript couldn't tell
whether the property was present. Now that Shape
is a union, TypeScript is telling us that shape
might be a
Square
, and Square
s don't have radius
defined on them! Both interpretations are correct, but only the union
encoding of Shape
will cause an error regardless of how
strictNullChecks
is configured.
But what if we tried checking the kind
property again?
function getArea(shape: Shape) {
if (shape.kind === 'circle') {
return Math.PI * shape.radius ** 2;
// Good! (parameter) shape: Circle
}
}
That got rid of the error! When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union.
In this case, kind
was that common property (which is what's considered a discriminant property of Shape
).
Checking whether the kind
property was "circle"
got rid of every type in Shape
that didn't have a kind
property
with the type "circle"
. That narrowed shape
down to the type Circle
.
The same checking works with switch
statements as well. Now we can try to write our complete getArea
without any
pesky !
non-null assertions.
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
}
The never
type
When narrowing, you can reduce the options of a union to a point where you have removed all possibilities and have
nothing left. In those cases, TypeScript will use a never
type to represent a state which shouldn't exist.
Exhaustiveness checking
The never
type is assignable to every type; however, no type is assignable to never
(except never
itself). This
means you can use narrowing and rely on never
turning up to do exhaustive checking in a switch statement.
For example, adding a default
to our getArea
function which tries to assign the shape to never
will raise when
every possible case has not been handled.
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Adding a new member to the Shape
union, will cause a TypeScript error:
interface Triangle {
kind: 'triangle';
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
// Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}
Using type predicates
We've worked with existing JavaScript constructs to handle narrowing so far, however sometimes you want more direct control over how types change throughout your code.
To define a user-defined type guard, we simply need to define a function whose return type is a type predicate:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
pet is Fish
is our type predicate in this example. A predicate takes the form parameterName is Type
, where
parameterName
must be the name of a parameter from the current function signature.
Any time isFish
is called with some variable, TypeScript will narrow that variable to that specific type if the
original type is compatible.
// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
Notice that TypeScript not only knows that pet
is a Fish
in the if
branch; it also knows that in the else
branch, you don't have a Fish
, so you must have a Bird
.
You may use the type guard isFish
to filter an array of Fish | Bird
and obtain an array of Fish
:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === 'sharkey') return false;
return isFish(pet);
});
Truthiness narrowing
In JavaScript, we can use any expression in conditionals, &&
, ||
, if
statements, Boolean negations (!
), and
more. As an example, if
statements don't expect their condition to always have the type boolean
.
In JavaScript, constructs like if
first “coerce” their conditions to boolean
s to make sense of them, and then choose
their branches depending on whether the result is true
or false
. Values like
0
NaN
""
(the empty string)0n
(thebigint
version of zero)null
undefined
all coerce to false
, and other values get coerced true
. You can always coerce values to boolean
s by running them
through the Boolean
function, or by using the shorter double-Boolean negation. (The latter has the advantage that
TypeScript infers a narrow literal boolean type true
, while inferring the first as type boolean
.)
// both of these result in 'true'
Boolean('hello'); // type: boolean, value: true
!!'world'; // type: true, value: true
It's fairly popular to leverage this behavior, especially for guarding against values like null
or undefined
. As an
example, let's try using it for our printAll
function.
function printAll(strs: string | string[] | null) {
if (strs && typeof strs === 'object') {
// not null and string[]
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === 'string') {
console.log(strs);
}
}
Keep in mind though that truthiness checking on primitives can often be error prone. As an example, consider a different
attempt at writing printAll
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// DON'T DO THIS!
// KEEP READING
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === 'object') {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === 'string') {
console.log(strs);
}
}
}
We wrapped the entire body of the function in a truthy check, but this has a subtle downside: we may no longer be handling the empty string case correctly.
TypeScript doesn't hurt us here at all, but this is behavior worth noting if you're less familiar with JavaScript. TypeScript can often help you catch bugs early on, but if you choose to do nothing with a value, there's only so much that it can do without being overly prescriptive. If you want, you can make sure you handle situations like these with a linter.
One last word on narrowing by truthiness is that Boolean negations with !
filter out from negated branches.
function multiplyAll(values: number[] | undefined, factor: number): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}