DEV Community

loading...
Cover image for Bringing Pattern Matching to TypeScript 🎨 Introducing TS-Pattern v3.0

Bringing Pattern Matching to TypeScript 🎨 Introducing TS-Pattern v3.0

Gabriel Vergnaud
I mostly write about #TypeScript — maker of ts-pattern — functional & reactive programming enthusiast — Dataviz Engineer @Datadog
Updated on ・11 min read

In the past few years, frontend development has become increasingly declarative. React shifted our mindsets from imperatively manipulating the DOM to declaratively expressing what the DOM should look like for a given state. It has been widely adopted by the industry, and now that we have realized how much easier it is to reason about declarative code and how many bugs are ruled out by embracing this paradigm, there is simply no going back.

It’s not only the user interface — state management libraries as well have been taking a declarative turn. Libraries such as XState, Redux, and many more let you declaratively manage your application state to unlock the same benefits: writing code that is easier to understand, modify and test. Today, we truly live in a declarative programming world!

Yet, Javascript and TypeScript weren’t designed for this paradigm, and these languages are lacking a very important piece of the puzzle: declarative code branching.

Declarative programming essentially consists of defining expressions rather than statements — that is, code that evaluates to a value. The big idea is to separate the code describing what needs to be done from the code that interprets this description in order to produce side effects. For instance, making a React app essentially consists of describing how the DOM should look using JSX, and letting React mutate the DOM in a performant way under the hood.

The problem with if, else and switch

If you have used React, you probably noticed that code branching inside JSX isn’t straightforward. The only way to use the if, else or switch statements we are used to is in self invoked functions (also called Immediately Invoked Function Expressions or IIFE for short):

declare let fetchState:
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error" };

<div>
  {
    (() => {
      switch (fetchState.status) {
        case "loading":
          return <p>Loading...</p>;
        case "success":
          return <p>{fetchState.data}</p>;
        case "error":
          return <p>Oops, an error occured</p>;
      }
    })() // Immediately invoke the function
  }
</div>;
Enter fullscreen mode Exit fullscreen mode

That's a lot of boilerplate and it doesn't look very nice. We can't blame React for this — it's just that imperative statements like if, else and switch (which do not return any value) do not fit well in a declarative context. We need expressions instead.

JavaScript does have a way to write code branching expressions: ternaries. But they have several limitations...

Ternaries are not enough

Ternaries are a concise way of returning two different values based on a boolean:

bool ? valueIfTrue : valueIfFalse;
Enter fullscreen mode Exit fullscreen mode

The simple fact that ternaries are expressions makes them the de facto way of writing code branches in React. Here's what most of our components look like nowadays:

const SomeComponent = ({ fetchState }: Props) => (
  <div>
    {fetchState.status === "loading" ? (
      <p>Loading...</p>
    ) : fetchState.status === "success" ? (
      <p>{fetchState.data}</p>
    ) : fetchState.status === "error" ? (
      <p>Oops, an error occured</p>
    ) : null}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Nested ternaries. They are a bit hard to read, but we just don't have any better option. What if we want to define and reuse a variable inside one of our branches? This seems pretty basic, but there is no straightforward way to do that with ternaries. What if we don’t want a default case and we just want to make sure we're handling all possible cases? This is called exhaustiveness checking, and guess what: we can’t do that with ternaries either.

The status quo of exhaustiveness checking

There are workarounds to make TypeScript check that a switch statement is exhaustive. One of them is to call a function that takes a parameter with the never type:

// This function is just a way to tell TypeScript that this code
// should never be executed.
function safeGuard(arg: never) {}

switch (fetchState.status) {
  case "loading":
    return <p>Loading...</p>;
  case "success":
    return <p>{fetchState.data}</p>;
  case "error":
    return <p>Oops, an error occured</p>;
  default:
    safeGuard(fetchState.status);
}
Enter fullscreen mode Exit fullscreen mode

This will only type-check if status has type never, which means that all possible cases are handled. This looks like a good solution, but if we want to do that in our JSX, we are back to an IIFE:

<div>
  {(() => {
    switch (fetchState.status) {
      case "loading":
        return <p>Loading...</p>;
      case "success":
        return <p>{fetchState.data}</p>;
      case "error":
        return <p>Oops, an error occured</p>;
      default:
        safeGuard(fetchState.status);
    }
  })()}
</div>
Enter fullscreen mode Exit fullscreen mode

Even more boilerplate.

What if we want to branch based on two values instead of one? Let's say we want to write a state reducer. It's considered good practice to branch both on the current state and on the action to prevent invalid state changes. The only option we have to ensure we are handling every case is to nest several switch statements:

type State =
  | { status: "idle" }
  | { status: "loading"; startTime: number }
  | { status: "success"; data: string }
  | { status: "error"; error: Error };

type Action =
  | { type: "fetch" }
  | { type: "success"; data: string }
  | { type: "error"; error: Error }
  | { type: "cancel" };

const reducer = (state: State, action: Action): State => {
  switch (state.status) {
    case "loading": {
      switch (action.type) {
        case "success": {
          return {
            status: "success",
            data: action.data,
          };
        }
        case "error": {
          return {
            status: "error",
            error: action.error,
          };
        }
        case "cancel": {
          // only cancel if the request was sent less than 2 sec ago.
          if (state.startTime + 2000 < Date.now()) {
            return {
              status: "idle",
            };
          } else {
            return state;
          }
        }
        default: {
          return state;
        }
      }
    }
    default:
      switch (action.type) {
        case "fetch": {
          return {
            status: "loading",
            startTime: Date.now(),
          };
        }
        default: {
          return state;
        }
      }

      safeGuard(state.status);
      safeGuard(action.type);
  }
};
Enter fullscreen mode Exit fullscreen mode

Even though this is safer, it’s a lot of code and it's very tempting to go for the shorter, unsafe alternative: only switching on the action.

There must be a better way to do this?

Of course there is. Once more, we need to turn our gaze to functional programming languages, and see how they have been doing it all this time: Pattern Matching.

Pattern matching is a feature implemented in many languages like Haskell, OCaml, Erlang, Rust, Swift, Elixir, Rescript… The list goes on. There is even a TC39 proposal from 2017 to add pattern matching to the EcmaScript specification (defining the JavaScript syntax and semantic). The proposed syntax looks like this:

// Experimental EcmaScript pattern matching syntax (as of March 2021)
case (fetchState) {
  when { status: "loading" } -> <p>Loading...</p>,
  when { status: "success", data } -> <p>{data}</p>,
  when { status: "error" } -> <p>Oops, an error occured</p>
}
Enter fullscreen mode Exit fullscreen mode

The pattern matching expression starts with the case keyword followed by the value we want to branch on. Each code branch starts with a when keyword followed by the pattern: the shape our value must match for this branch to be executed. If you know about destructuring assignements this should feel pretty familiar.

Here is how the previous reducer example would look with the proposal:

// Experimental EcmaScript pattern matching syntax (as of March 2021)
const reducer = (state: State, action: Action): State => {
  return case ([state, action]) {
    when [{ status: 'loading' }, { type: 'success', data }] -> ({
      status: 'success',
      data,
    }),
    when [{ status: 'loading' }, { type: 'error', error }] -> ({
      status: 'error',
      error,
    }),
    when [state, { type: 'fetch' }] if (state.status !== 'loading') -> ({
      status: 'loading',
      startTime: Date.now(),
    }),
    when [{ status: 'loading', startTime }, { type: 'cancel' }] if (startTime + 2000 < Date.now()), -> ({
      status: 'idle',
    })
    when _ -> state
  }
};
Enter fullscreen mode Exit fullscreen mode

So much better!

I didn't run any scientific study on this, but I believe that pattern matching takes advantage of our brain's natural ability for pattern recognition. A pattern looks like the shape of the value we want to match on, which makes the code much easier to read than a bunch of ifs and elses. It's also shorter and, most importantly, it's an expression!

I’m very excited about this proposal, but it’s still in stage 1 and it is unlikely to be implemented for at least several years (if ever).


Bringing Pattern matching to TypeScript

A year ago, I started working on what was then an experimental library implementing pattern matching for TypeScript: ts-pattern. At first, I didn’t expect that it would be possible to implement in userland something even close to native language support in terms of usability and type safety. It turns out I was wrong. After several months of work I realized that TypeScript’s type system was powerful enough to implement a pattern matching library with all the bells and whistles we can expect from native language support.

Today, I’m releasing the version 3.0 of ts-pattern 🥳🎉✨

Here is the same reducer written with ts-pattern:

import { match, select, when, not, __ } from 'ts-pattern';

const reducer = (state: State, action: Action) =>
  match<[State, Action], State>([state, action])
    .with([{ status: 'loading' }, { type: 'success', data: select() }], data => ({
      status: 'success',
      data,
    }))
    .with([{ status: 'loading' }, { type: 'error', error: select() }], error => ({
      status: 'error',
      error,
    }))
    .with([{ status: not('loading') }, { type: 'fetch' }], () => ({
      status: 'loading',
      startTime: Date.now(),
    }))
    .with([{ status: 'loading', startTime: when(t => t + 2000 < Date.now()) }, { type: 'fetch' }], () => ({
      status: 'idle',
    }))
    .with(__, () => state) // `__` is the catch-all pattern.
    .exhaustive();

`
Enter fullscreen mode Exit fullscreen mode

Perfectly fits in a declarative context

ts-pattern works in any (TypeScript) environment and with any framework or technology. Here is the React component example from earlier:

declare let fetchState:
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error" };

<div>
  {match(fetchState)
    .with({ status: "loading" }, () => <p>Loading...</p>)
    .with({ status: "success" }, ({ data }) => <p>{data}</p>)
    .with({ status: "error" }, () => <p>Oops, an error occured</p>)
    .exhaustive()}
</div>;
Enter fullscreen mode Exit fullscreen mode

No need for an IIFE, a safeGuard function or nested ternaries. It fits right in your JSX.

Compatible with any data structure

Patterns can be anything: objects, arrays, tuples, Maps, Sets, nested in any possible way:

declare let x: unknown;

const output = match(x)
  // Literals
  .with(1, (x) => ...)
  .with("hello", (x) => ...)
  // Supports passing several patterns:
  .with(null, undefined, (x) => ...)
  // Objects
  .with({ x: 10, y: 10 }, (x) => ...)
  .with({ position: { x: 0, y: 0 } }, (x) => ...)
  // Arrays
  .with([{ firstName: __.string }], (x) => ...)
  // Tuples
  .with([1, 2, 3], (x) => ...)
  // Maps
  .with(new Map([["key", "value"]]), (x) => ...)
  // Set
  .with(new Set(["a"]), (x) => ...)
  // Mixed & nested
  .with(
    [
      { type: "user", firstName: "Gabriel" },
      { type: "post", name: "Hello World", tags: ["typescript"] }
    ],
    (x) => ...)
   // This is equivalent to `.with(__, () => …).exhaustive();`
  .otherwise(() => ...)

Enter fullscreen mode Exit fullscreen mode

In addition, the type system will reject any pattern that doesn't match the input type!

Built with type safety and type inference in mind

For every .with(pattern, handler) clause, the input value is piped to the handler function with a type narrowed down to what the pattern matches.

type Action =
  | { type: "fetch" }
  | { type: "success"; data: string }
  | { type: "error"; error: Error }
  | { type: "cancel" };

match<Action>(action)
  .with({ type: "success" }, (matchedAction) => {
    /* matchedAction: { type: 'success'; data: string } */
  })
  .with({ type: "error" }, (matchedAction) => {
    /* matchedAction: { type: 'error'; error: Error } */
  })
  .otherwise(() => {
    /* ... */
  });
Enter fullscreen mode Exit fullscreen mode

Exhaustiveness checking support

ts-pattern nudges you towards safer code by making exhaustive matching the default:

type Action =
  | { type: 'fetch' }
  | { type: 'success'; data: string }
  | { type: 'error'; error: Error }
  | { type: 'cancel' };

return match(action)
  .with({ type: 'fetch' }, () => /* ... */)
  .with({ type: 'success' }, () => /* ... */)
  .with({ type: 'error' }, () => /* ... */)
  .with({ type: 'cancel' }, () => /* ... */)
  .exhaustive(); // This compiles

return match(action)
  .with({ type: 'fetch' }, () => /* ... */)
  .with({ type: 'success' }, () => /* ... */)
  .with({ type: 'error' }, () => /* ... */)
  // This doesn't compile!
  // It throws a `NonExhaustiveError<{ type: 'cancel' }>` compilation error.
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

You can still opt-out by using .run() instead of .exhaustive() if you really need to:

return match(action)
  .with({ type: 'fetch' }, () => /* ... */)
  .with({ type: 'success' }, () => /* ... */)
  .with({ type: 'error' }, () => /* ... */)
  .run(); // ⚠️ This is unsafe but it compiles
Enter fullscreen mode Exit fullscreen mode

Wildcards

If you need a pattern to always match, you can use the __ (wildcard) pattern. This is a pattern that matches anything:

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

match([state, event])
  .with(__, () => state)
  // You can also use it inside another pattern:
  .with([__, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */)
  // at any level:
  .with([__, { type: __ }], () => state)
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

It's also possible to match a specific type of input with __.string, __.boolean and __.number. It's especially useful when dealing with unknown values, maybe coming from an API endpoint:

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

type Option<T> = { kind: "some"; value: T } | { kind: "none" };
type User = { firstName: string; age: number; isNice: boolean };

declare let apiResponse: unknown;

const maybeUser = match<unknown, Option<User>>(apiResponse)
  .with({ firstName: __.string, age: __.number, isNice: __.boolean }, (user) =>
    /* user: { firstName: string, age: number, isNice: boolean } */
    ({ kind: "some", value: user })
  )
  .otherwise(() => ({ kind: "none" }));

// maybeUser: Option<User>
Enter fullscreen mode Exit fullscreen mode

When clauses

You can use the when helper function to make sure the input respects a guard function:

import { match, when, __ } from 'ts-pattern';

const isOdd = (x: number) => Boolean(x % 2)

match({ x: 2 })
  .with({ x: when(isOdd) }, ({ x }) => /* `x` is odd */)
  .with(__, ({ x }) => /* `x` is even */)
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

You can also call .with() with a guard function as second parameter:

declare let input: number | string;

match(input)
  .with(__.number, isOdd, (x) => /* `x` is an odd number */)
  .with(__.string, (x) => /* `x` is a string */)
  // Doesn't compile! the even number case is missing.
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

Or just use .when():

match(input)
  .when(isOdd, (x) => /* ... */)
  .otherwise(() => /* ... */);
Enter fullscreen mode Exit fullscreen mode

Property selection

When matching on a deeply nested input, it's often nice to extract pieces of the input to use in the handlers and avoid having to separately destructure the input. The select helper function enables you to do that:

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

type input =
  | { type: "text"; content: string }
  | { type: "video"; content: { src: string; type: string } };

match(input)
  // Anonymous selections are directly passed as first parameter:
  .with(
    { type: "text", content: select() },
    (content) => <p>{content}</p> /* content: string */
  )
  // Named selections are passed in a `selections` object:
  .with(
    { type: "video", content: { src: select("src"), type: select("type") } },
    ({ src, type }) => (
      <video>
        <source src={src} type={type} />
      </video>
    )
  )
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

Tiny

Since this library is mostly type-level code, it has a tiny bundle footprint: only 1.6kB once minified and gzipped!

Drawbacks

For the type inference and exhaustiveness checking to work properly, ts-pattern relies on type level computations that might slow down the typechecking of your project. I tried (and will continue to try) to make it as fast as possible, but it will always be slower than a switch statement. Using ts-pattern, means trading some compilation time for type safety and for code that is easier to maintain. If this trade-off doesn't appeal to you, that's ok! You don't have to use it!

Installation

You can install it from npm

npm install ts-pattern
Enter fullscreen mode Exit fullscreen mode

Or yarn

yarn add ts-pattern
Enter fullscreen mode Exit fullscreen mode

Conclusion

I love tools which make it easy to write better code. I was heavily inspired by ImmutableJS and Immer in that regard. Simply by providing a nicer API to manipulate immutable data structures, these libraries greatly encouraged the adoption of immutability in the industry.

Pattern matching is great because it nudges us towards writing code that is safer and more readable, and ts-pattern is my humble attempt to popularise this concept in the TypeScript community. ts-pattern v3.0 is the first LTS version. Now that the technical challenges are solved, this version focuses on performance and usability. I hope you will enjoy it.


Star it on GitHub ✨ if you think it’s exciting!


You can find the full API reference on the ts-pattern repository


👉 I posted the link on Hacker News don't hesitate to post a comment in the thread if you have any question, I'll try to answer to everyone!


PS: Shouldn't we just switch to languages supporting pattern matching, like Rescript?

I personally think we should! Rescript looks like a very promising language, and I would definitely pick it as my language of choice if I were to start a new project in 2021. We don't always have the luxury of starting a new project from scratch though, and the TypeScript code we write could benefit a lot from adopting pattern matching. Mine certainly would. I hope you found my case convincing 😉

PPS: Inspiration

This library was heavily inspired by the great article Pattern Matching in TypeScript with Record and Wildcard Patterns by Wim Jongeneel. Read it if you want to have a rough idea of how ts-pattern works under the hood.

👋 Cya!

Discussion (4)

Collapse
cakekindel profile image
Orion Kindel

Hi! Nice library you wrote here, very expressive and powerful. I wrote a very similar library with goals of simplicity and small size last year called matchbook

Collapse
stealthmusic profile image
Jan Wedel

Very impressive work! After I once used pattern matching in Erlang, I basically wanted every language to have it. Java is making baby steps and I would love to have built into the language. But this lib offers a very powerful alternative!

Collapse
clivend profile image
clivend • Edited

hi and thanks! how does this compare to Xstate in your opinion?

Collapse
gvergnaud profile image
Gabriel Vergnaud Author

Hi! IMO TS-Pattern and XState are complementary tools that aren't trying to solve the same problem:

  • XState is a state management library, trying to prevent invalid state transition from happening.
  • TS-Pattern is a control flow library, trying to make complex conditional expression easier to read and write, and trying to prevent unhandled cases.