Filtering arrays with TypeScript type guards
Spencer Miskoviak
March 31, 2022
Photo by Tyler Nix
Dealing with complex arrays that contain mixed types of elements can be a common task in frontend development. Inevitably individual elements need to be found and filtered from this array.
TypeScript can provide a layer of type-safety when working with these arrays that contain mixed types of elements. However, it can be tricky to properly reflect this in the type system.
Let's start with an example to demonstrate this.
Example
To begin, we'll define a few types that represent a few kinds of shapes.
// A square shape with a property for the size of the square's four sides.
interface Square {
type: "SQUARE";
size: number;
}
// A rectangle shape with properties for the rectangle's height and width.
interface Rectangle {
type: "RECTANGLE";
height: number;
width: number;
}
// A circle shape with a property for the circle's radius.
interface Circle {
type: "CIRCLE";
radius: number;
}
// A union type of all the possible shapes.
type Shape = Square | Rectangle | Circle;
This defines the types for three kinds of shapes: squares, rectangles, and circles.
Now, these can be used to define several shapes, and added to an array.
// Define two example circles.
const circle1: Circle = { type: "CIRCLE", radius: 314 };
const circle2: Circle = { type: "CIRCLE", radius: 42 };
// Define two example squares.
const square1: Square = { type: "SQUARE", size: 10 };
const square2: Square = { type: "SQUARE", size: 1 };
// Define two example rectangles.
const rectangle1: Rectangle = { type: "RECTANGLE", height: 10, width: 4 };
const rectangle2: Rectangle = { type: "RECTANGLE", height: 3, width: 5 };
// Define an array with all the shapes mixed.
const shapes = [circle1, square1, rectangle1, square2, circle2, rectangle2];
The shapes
array could be explicitly typed with Array<Shape>
but since all the shapes are strongly typed we can rely on inference. Here, the inferred type for shapes
is (Circle | Square | Rectangle)[]
, or equivalent to Array<Circle | Square | Rectangle>
.
Since Shape
is equivalent to Circle | Square | Rectangle
that means the inferred type is equivalent to Array<Shape>
due to TypeScript's structural typing, or duck typing.
That confirms the inferred type for shapes
is working as expected.
The challenge
Now, let's find the first square in this array using the find
method.
// Find and print the first square shape in the array.
const firstSquare = shapes.find((shape) => shape.type === "SQUARE");
console.log(firstSquare);
This will correctly print the first square (square1
) as expected.
{
"type": "SQUARE",
"size": 10
}
However, we run into trouble if we try to access the size
property of the square.
const firstSquare = shapes.find((shape) => shape.type === "SQUARE");
console.log(firstSquare?.size);
// ^^^^
// Property 'size' does not exist on type 'Square | Rectangle | Circle'.
// Property 'size' does not exist on type 'Rectangle'.(2339)
As the error message states, the inferred type for firstSquare
is Square | Rectangle | Circle | undefined
. The undefined
is handled by using optional chaining (?.
).
Since it's filtering to only squares, we'd expect firstSquare
to have the type Square | undefined
. This would then be valid code because size
does exist on Square
.
The next step is to understand where this inferred type comes from by inspecting the type definitions for find
provided directly by TypeScript.
// lib/lib.es2015.core.d.ts
interface Array<T> {
// ...
find(
predicate: (value: T, index: number, obj: T[]) => unknown,
thisArg?: any
): T | undefined;
// ...
}
The types define find
as a method that accepts two arguments. For the purposes of this we only care about the first argument: predicate
.
The predicate
is a testing function that defines the criteria for what to find within the array. It's type signature defines three arguments, but again for the purposes of this we only care about the first argument: value
.
The Array
interface provides a generic T
which represents the type of elements in the array. In this example, T
would be the inferred type of the elements in shapes
covered earlier: Square | Rectangle | Circle
.
This generic T
is then also used for the value
passed to the predicate
testing function since each element in the array can be passed as a value
.
The return type of find
is T | undefined
since undefined
is returned when no element in the array matches the predicate. From the example above, we expected firstSquare
to be of type Square
. However, the return type includes the generic T
which is the type that represents all elements in the array, or in this case: Square | Rectangle | Circle
.
There is no way for TypeScript to know what the predicate function filters on so from it's perspective any element in the array could be returned. What if we could inform TypeScript so it could properly narrow the type to only the element we're finding?
Casts
The simplest "fix" to this problem is to cast the result of find
.
const firstCircle = shapes.find((shape) => shape.type === "SQUARE") as Square;
console.log(firstCircle?.size);
This works as expected, but casting covers up type errors and can lead to long term maintenance headaches. For example, if the predicate condition was changed to shape.type === "CIRCLE"
this would compile with no type errors and at runtime it would print undefined
since circles don't have a size
property.
An ideal solution would avoid the need for casts by properly narrowing the inferred value to only Square
. Fortunately, there's another solution!
Type guards
TypeScript provides a number of ways to narrow types. In addition to supporting existing JavaScript constructs such as typeof
as type guards, TypeScript also allows user defined type guards using type predicates (eg: shape is Square
).
We can define a corresponding type guard for each shape.
const isSquare = (shape: Shape): shape is Square => shape.type === "SQUARE";
const isCircle = (shape: Shape): shape is Circle => shape.type === "CIRCLE";
const isRectangle = (shape: Shape): shape is Rectangle =>
shape.type === "RECTANGLE";
Each of these type guards accept any Shape
and return a boolean if the passed shape
is of that type or not. For example, the isSquare
type guard defines the type predicate shape is Square
which informs TypeScript if the passed shape
is a Square
or not.
These type guards can be combined with if
statements to access properties only on a specific shape. It returns a boolean so within this block TypeScript knows it must be type Square
which allows safely accessing the size
property.
const shape: Shape = square1;
if (isSquare(shape)) {
console.log(shape.size);
}
Function overload
When you inspected the find
type definitions you may have noticed an additional type deceleration. This is an overload signature meaning there are multiple function signatures that could be used, depending on the context.
TypeScript chooses the first matching overload when resolving function calls. When an earlier overload is “more general” than a later one, the later one is effectively hidden and cannot be called. - TypeScript documentation
Below we can see the original type deceleration for find
we were looking at above along with an overload.
interface Array<T> {
// ...
// Type declaration overload.
find<S extends T>(
predicate: (this: void, value: T, index: number, obj: T[]) => value is S,
thisArg?: any
): S | undefined;
// Type declaration from above.
find(
predicate: (value: T, index: number, obj: T[]) => unknown,
thisArg?: any
): T | undefined;
// ...
}
If we look at the find
overload signature again you'll notice another generic S
and a type predicate value is S
.
interface Array<T> {
// ...
find<S extends T>(
predicate: (this: void, value: T, index: number, obj: T[]) => value is S,
thisArg?: any
): S | undefined;
// ...
}
This overload includes the generic S
with a constraint (see this post on extends
). In this case, S
must extend the inferred type Square | Rectangle | Circle
.
This overload also includes a type predicate value is S
where S
is the generic that TypeScript can infer when the predicate
argument's return type is a type predicate.
This means we can pass the type guard defined above directly to the find
method and TypeScript will infer the type for S
from the type predicate. It then returns S | undefined
as opposed to T | undefined
.
In this case, since the isSquare
type guard's type predicate states shape is Square
the S
generic will be inferred as type Square
and the return type will then be Square | undefined
. Exactly what we want!
Updating the original example to use this type guard now works as expected.
const firstSquare = shapes.find(isSquare);
console.log(firstSquare?.size);
This will print the size
of the first square without any type errors or hacks like casting. The type guard can be swapped with isCircle
or isRectangle
but a type error will be raised for accessing size
so try swapping to the radius
or height
properties, respectively.
Gotcha
One thing to be aware of is that the type guard needs to be passed directly to the find
method.
const firstCircle = shapes.find((shape) => isCircle(shape));
console.log(firstCircle?.radius);
// ^^^^^^
// Property 'radius' does not exist on type 'Square | Rectangle | Circle'.
// Property 'radius' does not exist on type 'Square'.
This example results in a type error since the predicate
argument passed to find
does not define a type predicate itself (shape is Circle
) so there is nothing for TypeScript to infer and it falls back to the more general find
definition.
The type predicate can be included which does then work as expected. However, it does act a bit like a cast since it allows any boolean return value.
const firstCircle = shapes.find((shape): shape is Circle => isCircle(shape));
console.log(firstCircle?.radius);
filter
If you inspect the types for the filter
method you'll notice identical behavior.
interface Array<T> {
// ...
filter<S extends T>(
predicate: (value: T, index: number, array: T[]) => value is S,
thisArg?: any
): S[];
filter(
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: any
): T[];
// ...
}
The filter
method provides two overloads, one which can infer the type of the type predicate (value is S
) as the generic value (S
) and returns that inferred type as an array (S[]
).
const onlyCircles = shapes.filter(isCircle);
onlyCircles.forEach((circle) => console.log(circle.radius));
In this example the type of onlyCircles
is inferred as Circle[]
since the type guard isCircle
was passed directly to filter
. The first type deceleration is used which will infer the generic S
as Circle
since the isCircle
helper defines the predicate shape is Circle
.
Combining
As mentioned above, the type inference doesn't work properly unless the type guard is passed directly to find
or filter
. What about cases where you want to find a square with a certain size?
const sizeOneSquare = shapes.find(
(shape) => isSquare(shape) && shape.size === 1
);
console.log(sizeOneSquare?.size);
This example might be the first thing that comes to mind but faces the same problem since the type guard isn't passed directly so the return type isn't properly narrowed.
A solution is to combine both filter
and find
.
const firstSquare = shapes.filter(isSquare).find((shape) => shape.size === 1);
console.log(firstSquare?.size);
The filter
method first filters to only squares and properly narrows the type to Square[]
since the type guard was passed directly. The find
method then finds the exact square based on the size and returns the same type it received, which is Square
.
Final thoughts
While these examples are specific to a few array methods, there are some takeaways.
- When trying to improve type-safety it's important to inspect and understand third-party types to know what they do and don't support. The type definitions can inform how these are expected to be used and uncover unique use cases like using type guards directly.
- Type predicate inference with generics is possible. I was unaware of this before inspecting these types. Granted the need for this in practice is likely limited unless working on complex utilities, but is a great tool to have.
The next time you find yourself working with an array of mixed elements consider if a type guard could be helpful.
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course