Filtering arrays with TypeScript type guards

profile picture

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