André König

Pattern Matching in TypeScript

How Pattern Matching Can Help You Write Readable Code

In my opinion, code that is easy to comprehend is highly valuable because, ultimately, its essence lies in its correctness and readability. It should be comprehensible not just for other team members, but also for myself when I review specific sections weeks or months down the line. Enhanced readability saves everyone time, which is ultimately a cost-saving measure.

As much as I appreciate working with TypeScript, sometimes I find myself envying other programming languages because TS lacks one missing feature: Pattern Matching.

In this article, I will explain why so and how Pattern Matching can be implemented in TypeScript, even though it is not a native feature of the language.

Too Much Spaghetti Considered Harmful

When it comes to creating different paths in your code, the only option often seems to be using if-else statements to handle specific cases. Anyone who has encountered a cascade of if / else if / else if / else if / else statements will understand precisely what I'm referring to.

Just use a switch statement and call it a day.

If you have a simple case, you might consider using a switch statement. However, when it comes to more complex matching logic, relying on switch statements can lead to runtime bugs. I promise you.

The Problem with Switch Statements

Besides unrolling Spaghetti, additionally, the switch statement is quite limited:

  • Accidental fall through: You missed the break statement? Your user will find out.
  • Variable scoping: You have to use curly braces in a case branch, otherwise defined variables in the branch are available in all others branches as well.
  • Limited comparison: You can only compare the value to the exact value in the case. Range matching or matching based on a specific structure is not possible.

Again, for simple cases it is sufficient, but consider the following example where we have an event data structure which usually exists in an e-commerce system.

type ProductWasAddedToCartEvent = Readonly<{
type: "ProductWasAddedToCart";
payload: Readonly<{
productId: string;
quantity: number;
}>;
}>;
type ProductWasRemovedFromCartEvent = Readonly<{
type: "ProductWasRemovedFromCart";
payload: Readonly<{
productId: string;
}>;
}>;
type CartEvent = ProductWasAddedToCartEvent | ProductWasRemovedFromCartEvent;

Matching one particular event type is possible with a switch statement:

switch (event.type) {
case "ProductWasAddedToCart": {
console.log(event.payload); // => {"productId": "...", "quantity": 10}
break;
}
case "ProductWasRemovedFromCart": {
console.log(event.payload); // => {"productId": "..."}
break;
}
}

What if we want to match not just on one, but multiple values?

Give me the ProductWasAddedToCart event when the quantity of the added product is 2.

It's not as straightforward, is it? First, a switch statement does not "evaluate to a value." This means that it cannot be treated like a function, where a value is returned from a branch and assigned to a variable. Second, in the ProductWasAddedToCart branch of the switch statement, an if condition is required to check the quantity. This results in a more complex and less readable code structure.

To be more precisely: It will become a mess. If not now, then in the future, because, hey, it is software and business requirements will change.

As mentioned in the introduction, there are languages which borrowed a concept from functional programming. So why can't we do the same in TypeScript land?

We can!

What is Pattern Matching?

Pattern matching is a technique for branching code that is commonly found in functional programming languages. Unlike simple value comparisons, pattern matching enables more comprehensive checks and conditions.

It is basically a switch statement with superpowers.

Considering our earlier example, we can demonstrate pattern matching using the following pseudocode:

var matchedEvents = match (event) =>
# A code-branch with the matching condition
event.type === "ProductWasAddedToCart" && event.paylod.quantity === 2

The first important aspect to notice is that the result of the expression will be assigned to the variable matchedEvents. Although this is a simple example with just one branch, it is evident that such a concept would be immensely powerful in TypeScript as well.

Although, there is a TC39 proposal for adding pattern matching as a native language feature, it is currently at stage 1. That said, it will take some time until we see it natively.

Luckily, we don't have to wait for the TC39 process here, as this is also solvable in user land.

In recent years, a couple of well-designed libraries popped up which implemented this mechanism and exposes it as a DSL.

One of these libraries is ts-pattern.

Our First Supercharged Switch Statement

ts-pattern by Gabriel Vergnaud is an awesome library. It allows pattern matching in TypeScript with sophisticated type inference, all exposed via an easy-to-use API.

It is basically a drop-in replacement for situations where you usually would utilize a switch statement (and deeply nested if conditions within a branch of the switch statement).

ts-pattern catapults switch statements into the modern age of software engineering.

To keep things consistent, we'll migrate our previously described pseudocode into TypeScript code and implement pattern matching via ts-pattern:

import { match } from "ts-pattern";
const event: ProductWasAddedToCartEvent = {
type: "ProductWasAddedToCart",
payload: {
productId: "112312",
quantity: 2,
},
};
const result = match(event)
.with(
{ type: "ProductWasAddedToCart", payload: { quantity: 2 } },
(event) => event,
)
.otherwise(() => undefined);
console.log(result); // => ProductWasAddedToCartEvent

First, we start by creating an object literal of the ProductWasAddedToCart event. This object is then passed to the match function. For demonstrative purposes, the match function has only one branch, which matches all events of the type ProductWasAddedToCart and whose quantity in the payload equals 2. If this branch matches, the whole event is returned, and so the result variable would contain the actual event object.

The otherwise branch serves as a fallback, providing a default value in case none of the defined branches match. Here we simply return undefined.

Although this is a straightforward example, it already demonstrates the expressive nature of pattern matching.

ts-pattern is a powerful library. It also includes helper functions for constructing range cases. For instance, if we want to modify our previous example to retrieve all ProductWasAddedToCart events where the quantity is greater than 10, we can use the following pattern matcher, P.number.gt:

import { match, P } from "ts-pattern";
const event: ProductWasAddedToCartEvent = {
type: "ProductWasAddedToCart",
payload: {
productId: "112312",
quantity: 2,
},
};
const result = match(event)
.with(
{ type: "ProductWasAddedToCart", payload: { quantity: 2 } },
(event) => event,
)
.with(
{ type: "ProductWasAddedToCart", payload: { quantity: P.number.gt(100) } },
(event) => event,
)
.otherwise(() => undefined);
console.log(result); // => ProductWasAddedToCartEvent

You might wonder:

Is this really more readable?

Not at first glance, but remember, these are just object literals, which can be modularized and imported respectively. So let's get creative and push the boundaries!

Imagine a module conditions.ts with the following structure:

import { P } from "ts-pattern";
export const conditions = {
"type = ProductWasAddedToCart AND quantity = 2": {
type: "ProductWasAddedToCart",
payload: { quantity: 2 }
},
"type = ProductWasAddedToCart AND quantity > 100": {
type: "ProductWasAddedToCart",
payload: { quantity: P.number.gt(100) } }
}
};

In essence, what we've done is centralize our conditions, providing them with descriptive names that we can then use like so:

import { match } from "ts-pattern";
import { conditions as c } from "./conditions";
const event: ProductWasAddedToCartEvent = {
type: "ProductWasAddedToCart",
payload: {
productId: "112312",
quantity: 2,
},
};
const result = match(event)
.with(
c["type = ProductWasAddedToCart AND quantity = 2"],
(event) => event)
.with(
c["type = ProductWasAddedToCart AND quantity > 100"],
(event) => event,
)
.otherwise(() => undefined);
console.log(result); // => ProductWasAddedToCartEvent

Admittedly, it may be a rather unconventional example. However, the purpose is to emphasize that due to the composable nature of pattern matching, you have the freedom to be creative and craft more readable and expressive code.

Use Cases

You might wonder where pattern matching would be useful. Generally, it proves beneficial when dealing with deeply nested code branches. Defining the shape you want to match is extremely helpful, and the helper functions provided by ts-pattern further facilitate handling specific comparisons.

I would like to wrap this article by providing two possible use cases which I personally recommend where pattern matching might come handy.

Declarative Selectors

In our previous example, we worked with domain events from an e-commerce system. With this declarative style, we can also push this concept further and define re-usable functions for selecting specific data structures.

function ProductsWithHighQuantityWereAddedToCart(quantity: number) {
return function (event: CartEvent) {
return match(event)
.with(
{
type: "ProductWasAddedToCart",
payload: { quantity: P.number.gt(quantity) },
},
(event) => event,
)
.otherwise(() => undefined);
};
};

This composability is super powerful when combined with Array.prototype.filter(). For example, when we have an array of CartEvents, we could create a declarative pipeline which gives us all events with a product quantity higher than 100:

const cartEventStream: CartEvent[] = [
{
type: "ProductWasAddedToCart",
payload: {
productId: "1",
quantity: 2,
},
},
{
type: "ProductWasRemovedFromCart",
payload: {
productId: "1",
},
},
{
type: "ProductWasAddedToCart",
payload: {
productId: "2",
quantity: 300,
},
},
{
type: "ProductWasAddedToCart",
payload: {
productId: "3",
quantity: 320,
},
},
];
const filteredEvents = events
.filter(
ProductsWithHighQuantityWereAddedToCart(100)
);
console.log(filteredEvents);

The filteredEvents array contains the ProductWasAddedToCart events with the quantity 300 and 320.

Combining these selectors can result in a nicely composed code structure.

Conditional UI Component Rendering

Another excellent application for pattern matching is the conditional rendering of UI components, such as when using React.

export async function loader() {
const articles = await fetchArticlesFromDatabase();
return { articles };
}
function ArticleCategory(article: Article) {
return match(article)
.with({ category: "tut"}, () =>
<Label className="border-red-500">Tutorial</Label>
)
.with({ category: "howto"}, () =>
<Label className="border-blue-500">HowTo</Label>
)
.otherwise(() => <Label>ERROR</Label>)
}
export default function ArticlesPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<main>
<h2>My Articles</h2>
<ul>
{articles.map((article) => (
<li key={article.id}>
<ArticleCategory {...article} />
</li>
))}
</ul>
</main>
);
}

This example utilizes Remix, but it is applicable to every approach which uses a component-based view representation. As you can see, the ArticlesPage is a route which gets populated with article data via the useLoaderData hook. Then, we take these articles and map over them in order to render list items. Each list item uses the ArticleCategory component, which we defined above. This component contains the magic, as it uses pattern matching in order to render a specifically styled label component based on the respective category of that article.

Conclusion

Pattern matching is a powerful construct that can result in more structured code branching than currently available native language features like if / else / switch statements. Even though pattern matching is not natively supported, we can utilize this functionality by using libraries such as ts-pattern.

Personally, I would recommend using it whenever your code branches start to resemble a tangled dish of spaghetti.

A little disclaimer on the declarative selectors use case, though: Composing selectors is quite powerful, but it shouldn't replace filtering the data on the storage / database level:

  • When the data comes from a database, make sure to use the filter / query capabilities baked into the system itself (SQL, etc.).
  • When the data comes out of an API, make sure to verify if it doesn't expose specific filter mechanisms.

If none of these aspects are viable options, it is highly recommended to use such a declarative filter pipeline in the end.


Thank You

I hope that you found this article insightful and valuable for your journey. If so, and you learned something new or would like to give feedback then let's connect on X at @ItsAndreKoenig. Additionally, if you need further assistance or have any queries, feel free to drop me an email or send me an async message.

One last thing!

Let me know how I'm doing by leaving a reaction.


You might also like these articles