Michal Zalecki Senior Engineer at @Tooploox 💎, smart contracts, fan of hackathons, React Wrocław meetup organizer.

Pattern matching and type safety in TypeScript

8 min read 2273

Editor’s note: This article was last updated on 18 April 2023 to include information about some popular TypeScript pattern matching libraries, including TS-Pattern, Unionize, and Pratica.

Most programmers understand type safety as a programming language feature that eliminates type errors. As a statically typed superset of JavaScript, TypeScript addresses this issue, especially in strict mode, which is more rigorous and performs additional checks.

With that said, I’m more interested in understanding type safety as the extent of program correctness rather than just making sure that what I expect to be a string is a string and not a number.

In this article, we’ll present several techniques that you can apply when working on day-to-day tasks to increase your confidence that your code is correct. Let’s get started!

Jump ahead:

Algebraic data types: Either left or right

Often in our programs, the majority of bugs are caused by either handling a particular case incorrectly or not handling it at all. This is a very broad definition of what causes bugs, but the remedy is also generic and has many applications. To address issues that originate from incorrectly handling decisions in our code, we use algebraic data types, a popular concept in functional programming.

An algebraic data type is a kind of composite type, a type that is a result of combining a few types together. Sound familiar? There is a similar construct in TypeScript called a union type:

type Either = "left" | "right";

const result1: Either = "left";
const result2: Either = "right";
const result3: Either = "forward";

In the code above, TypeScript will throw an error for result3 because it’s not assignable to the type Either. The result type accepts only left or right values, and forward isn’t assignable to the type Either. Is this an algebraic data type we’re looking for? Not yet, first, we need an interface:

type Left = { tag: "left", error: Error };
type Right<T> = { tag: "right", result: T };
type Either&lt;T> = Left | Right<T>;

Either is now a tagged union, or a discriminated union. The TypeScript type system is structural, and a tagged union is as close as we can get to an algebraic data type in TypeScript. This notation is actually very close to how we can export algebraic data types to JSON in purely functional languages like Haskell.

What’s the benefit of such an approach? While it might look like unnecessary boilerplate, it pays off. We can now simulate pattern matching with a switch statement:

Simulate Pattern Matching Switch Statement Typescript Union Type

The function allows us to match on a tag of a value of type Either<string>. Right away, we get a hint on all values from which we can choose:

Match Tag Value Type Either String

Now, TypeScript knows that the only available members of the left value are tag and error. The result is only available on the right type, which we know doesn’t fall under the tag that equals left:

Right Type Result Equal Left Tag

I forgot to handle the right case! By specifying the match return type explicitly, TypeScript can warn me about any cases I forgot to handle:

function match (value: Either <string>): string {
  switch(value.tag) {
      case "left":
          return value.error.message;
      case "right":
          return value.result;
      default:
        //Type guard
        const _exhaustiveCheck: never = value;
        return _exhaustiveCheck;
  }
}

We’ve also added a type guard to ensure that all possible cases are handled. Now, match returns a string for every case of value it accepts. Now, you should have an understanding of which algebraic data types can be useful.

Pattern matching with a switch statement is widely used because it is both type-safe and easy to understand. The TypeScript compiler can detect if you miss any case in the switch statement, and you can use the type system to enforce the correct properties for each variant.

We can implement the reusable implementation of match using callbacks:

type Left<T> = { tag: "left", value: T };
type Right<T> = { tag: "right", value: T };
type Either<L, R> = Left<L> | Right<R>;
function match<T, L, R>(
    input: Either<L, R>,
    left: (left: L) => T,
    right: (right: R) => T,
) {
    switch (input.tag) {
        case "left":
            return left(input.value);
        case "right":
            return right(input.value);
    }
}
function validate(): Either<Error, { name: string }> {
}
const value: string | null
const value = match(
    validate(),
    _left => null, // _left: Error
    right => right.name, // right: { name: string }
);

What’s really neat about this particular match implementation is that as long as you get the type of the input right, the rest of the types for a match call are inferred, and no additional typing is required.

Before we look into a more complex example, have you heard about the Either type before? There’s a good chance you have!

We often use the Either type when we have to handle either of two cases. By convention, left is used to hold an error, and right is used to hold the correct value. If you have a problem remembering the order, think about how the correct value belongs on the “right” side. If that doesn’t stick, consider the arguments and callbacks we’re used to in Node.js:

import fs from "fs";
fs.readFile("input.txt", (err, data) => {
  if (err) return console.error(err);
  console.log(data.toString());
});

The first argument on the left side of the arguments list is an error, and the second on the right side of the arguments list is a result. If you’re interested in algebraic data types, take a look at fp-ts, a library that defines many different algebraic data types and has a rich ecosystem.

Pattern matching libraries in TypeScript

There are several pattern matching libraries available for TypeScript that provide more expressive and concise ways to perform pattern matching. These libraries offer different APIs and features; depending on your use case and personal preference, you can choose the one that best fits your needs.

TS-Pattern

TS-Pattern is a lightweight library that allows you to use pattern matching with TypeScript in a functional programming style. It provides an intuitive and type-safe API to match patterns and destructure data. Some of its features include:

  • Exhaustiveness checking: Ensures that all cases are handled in a match expression
  • Wildcard pattern: Matches any value
  • Predicate pattern: Matches values satisfying a predicate function
  • Union pattern: Matches values belonging to any of the specified patterns

The code below shows an example usage:

import { match, __ } from "ts-pattern";

type Shape = Circle | Rectangle | Triangle;

interface Circle {
  kind: "circle";
  radius: number;
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

const getArea = (shape: Shape): number => {
  match(shape)
    .with({ kind: "circle" }, ({ radius }) => Math.PI * radius * radius)
    .with({ kind: "rectangle" }, ({ width, height }) => width * height)
    .with({ kind: "triangle" }, ({ base, height }) => 0.5 * base * height)
    .exhaustive();
}

const circle: Circle = { kind: "circle", radius: 5 };
const rectangle: Rectangle = { kind: "rectangle", width: 4, height: 6 };
const triangle: Triangle = { kind: "triangle", base: 3, height: 4 };

console.log(getArea(circle)); // 78.53981633974483
console.log(getArea(rectangle)); // 24
console.log(getArea(triangle)); // 6

In the code snippet above, we use the TS-Pattern library to create a union type, which is also called a sum type, and a function to calculate the area of different shapes using pattern matching.

match is a function from the TS-Pattern library that allows you to perform pattern matching in a functional programming style. __ is a wildcard pattern that can be used to match any value.



Unionize

Unionize is a TypeScript library that focuses on creating tagged unions and action creators with minimal boilerplate. It provides an API that allows you to concisely define and match against union types:

import { Unionize, ofType } from "unionize";

const Shapes = Unionize({
  Circle: ofType<{ radius: number }>(),
  Rectangle: ofType<{ width: number; height: number }>(),
  Triangle: ofType<{ base: number; height: number }>(),
});

type Shape = typeof Shapes._Union;

function getArea(shape: Shape): number {
  switch (Shapes.match(shape)) {
    case "Circle":
      const { radius } = shape;
      return Math.PI * radius * radius;
    case "Rectangle":
      const { width, height } = shape;
      return width * height;
    case "Triangle":
      const { base, height } = shape;
      return 0.5 * base * height;
  }
}

In the code snippet above, we use the Unionize function from the Unionize library to create a tagged union, also known as a sum type. We also create a function to calculate the area of different shapes using pattern matching. ofType is a helper function that creates a type definition for each variant of the union.

Pratica

Pratica is a functional programming library for TypeScript that provides tools for creating and working with sum and product types, including pattern matching. It supports features like exhaustive pattern matching, partial application, and currying:

import { Union, of } from "pratica";

const Shape = Union({
  Circle: of<{ radius: number }>(),
  Rectangle: of<{ width: number; height: number }>(),
  Triangle: of<{ base: number; height: number }>(),
});

const getArea = Shape.match({
  Circle: ({ radius }) => Math.PI * radius * radius,
  Rectangle: ({ width, height }) => width * height,
  Triangle: ({ base, height }) => 0.5 * base * height,
});

In the code above, we use the Union function from the Pratica library to define a sum type or a tagged union. We also create a function to calculate the area of different shapes using pattern matching. of is a helper function that creates a constructor for each variant of the sum type.

Type-safe reducers

We can apply the very same technique we used to come up with our Either type, where using a switch statement is already popular, in Redux’s reducer. Instead of having only a binary option of left or right, we can have as many options as we have action types to handle.

Thanks to accurate autocompletion, we strive to optimize for reducer correctness and ease of development:

enum ActionTypes {
  REQUEST_SUCCESS = "REQUEST_SUCCESS",
  REQUEST_FAILURE = "REQUEST_FAILURE",
}
type SFA<T, P> = { type: T, payload: P };
const createAction = <T extends ActionTypes, P>(
  type: T,
  payload: P
) : SFA<T, P> => ({ type, payload });
const success = (payload: { items: Todo[] }) =>
  createAction(ActionTypes.REQUEST_SUCCESS, payload);
const failure = (payload: { reason: string }) =>
  createAction(ActionTypes.REQUEST_FAILURE, payload);
const actions = { success, failure };
type Action = ReturnType<typeof actions[keyof typeof actions]>;
type Todo = { id: string };
type State = { items: Todo[] , error: string };
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case ActionTypes.REQUEST_SUCCESS:
      return { ...state, items: action.payload.items, error: "" };
    case ActionTypes.REQUEST_FAILURE:
      return { ...state, items: [], error: action.payload.reason };
  }
  return state;
}

In the code above, I defined action types as a string enum. The SFA type stands for a standard flux action and can be overloaded together with createAction to accommodate more action shapes, but this is not the most important step at the moment.

The interesting part is how we built the Action type. Using ReturnType, we can obtain types of actions returned by action creators directly from the actions object:

Typescript ReturnType Obtain Actions

This significantly reduces the amount of typing we have to do in every reducer without compromising type safety.

Runtime types

Have you ever defined a type for the JSON payload? In general, HTTP clients allow you to do that, but you have no guarantee that what you actually fetch will match the interface you have specified. Here, runtime types come in handy.

The io-ts library adopts this approach and blurs the boundary between what’s possible to type statically and what otherwise would require writing defensive code and custom type guards:

import axios from "axios"; 14.4K (gzipped: 5.1K)
import * as t from "dio-ts"; 24.8K (gzipped: 6K)

const Repository = t.type({
  description: t.string,
  stargazers_count: t.number,
})

type IRepository = t.TypeOf<typeof Repository>;

Defining Repository, a runtime type, is as effortless as defining an interface in TypeScript. It’s also possible to extract the static type from the runtime type using TypeOf:

(async () => {
    const response = await axios.get("https://api.github.com/repos/Microsoft/TypeScript");
    const repo = Repository.decode(response.data);
    const starts: number
    const starts = repo.fold(
        _errors => 0,
        repo => repo.stargazers_count
    )
    console.log(starts);
})();

I can fetch the payload without worrying about specifying the type of the response. I decode the payload to what I expect to be the Microsoft/TypeScript GitHub repository.

I only have to define the fields I’m interested in. Calling fold on the repo is similar to how we used the match function. In fact, the repo is of type Either, which has a slightly different implementation than our Either, but the idea is the same. The left value is a list of errors that prevented the payload from parsing correctly, and the right value is the repository.


More great articles from LogRocket:


Conclusion

When working with complex data structures or discriminating unions, pattern matching is especially useful. Although TypeScript doesn’t have native pattern matching, its community has created several pattern matching libraries that offer more expressive and concise ways to perform pattern matching.

I intentionally tried to avoid throwing and handling errors in the provided examples. Error handling isn’t an afterthought; we model software treating errors as part of the domain. It’s not easy, but I found it to be a great learning experience.

I would also encourage you to avoid using any in your interfaces and function signatures. It’s an escape hatch that quickly propagates across all of its consumers, either forcing you to lose the benefits of static typing or assert types, explicitly using as syntax or guard function.

I hope the examples provided give you some guideline on how you can incorporate algebraic data types into your own project. Don’t forget to try out io-ts yourself!

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Michal Zalecki Senior Engineer at @Tooploox 💎, smart contracts, fan of hackathons, React Wrocław meetup organizer.

Leave a Reply