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>;
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;
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>
);
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);
}
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>
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);
}
};
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 2023)
match (fetchState) {
when ({ status: "loading" }): <p>Loading...</p>
when ({ status: "success", data }): <p>{data}</p>
when ({ status: "error" }): <p>Oops, an error occured</p>
}
The pattern matching expression starts with the match
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 2023)
const reducer = (state: State, action: Action): State => {
return match ([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
}
};
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 if
s and else
s. 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, P } from 'ts-pattern';
const reducer = (state: State, action: Action) =>
match<[State, Action], State>([state, action])
.with([{ status: 'loading' }, { type: 'success', data: P.select() }], data => ({
status: 'success',
data,
}))
.with([{ status: 'loading' }, { type: 'error', error: P.select() }], error => ({
status: 'error',
error,
}))
.with([{ status: P.not('loading') }, { type: 'fetch' }], () => ({
status: 'loading',
startTime: Date.now(),
}))
.with([{ status: 'loading', startTime: P.when(t => t + 2000 < Date.now()) }, { type: 'fetch' }], () => ({
status: 'idle',
}))
.with(P._, () => state) // `P._` is the catch-all pattern.
.exhaustive();
`
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>;
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(P.array({ firstName: P.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(() => ...)
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(() => {
/* ... */
});
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();
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
Wildcards
If you need a pattern to always match, you can use the P._
(wildcard) pattern. This is a pattern that matches anything:
import { match, P } from 'ts-pattern';
match([state, event])
.with(P._, () => state)
// You can also use it inside another pattern:
.with([P._, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */)
// at any level:
.with([P._, { type: P._ }], () => state)
.exhaustive();
It's also possible to match a specific type of input with P.string
, P.boolean
and P.number
. It's especially useful when dealing with unknown
values, maybe coming from an API endpoint:
import { match, P } 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: P.string, age: P.number, isNice: P.boolean }, (user) =>
/* user: { firstName: string, age: number, isNice: boolean } */
({ kind: "some", value: user })
)
.otherwise(() => ({ kind: "none" }));
// maybeUser: Option<User>
When clauses
You can use the when
helper function to make sure the input respects a guard function:
import { match, P } from 'ts-pattern';
const isOdd = (x: number) => Boolean(x % 2)
match({ x: 2 })
.with({ x: P.when(isOdd) }, ({ x }) => /* `x` is odd */)
.with(P._, ({ x }) => /* `x` is even */)
.exhaustive();
You can also call .with()
with a guard function as second parameter:
declare let input: number | string;
match(input)
.with(P.number, isOdd, (x) => /* `x` is an odd number */)
.with(P.string, (x) => /* `x` is a string */)
// Doesn't compile! the even number case is missing.
.exhaustive();
Or just use .when()
:
match(input)
.when(isOdd, (x) => /* ... */)
.otherwise(() => /* ... */);
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: P.select() },
(content) => <p>{content}</p> /* content: string */
)
// Named selections are passed in a `selections` object:
.with(
{ type: "video", content: { src: P.select("src"), type: P.select("type") } },
({ src, type }) => (
<video>
<source src={src} type={type} />
</video>
)
)
.exhaustive();
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
Or yarn
yarn add ts-pattern
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?
Some languages like Rescript support pattern-matching and compile to JS. If I were to start a new project, I personally would love to try them! 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!
[Update April 2023]: Update examples to use TS-Pattern v4 instead of v3.
Top comments (10)
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!
Same here. Programming in Erlang is quite elegant and enjoyable.
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
Awesome, thank you
hi and thanks! how does this compare to Xstate in your opinion?
Hi! IMO TS-Pattern and XState are complementary tools that aren't trying to solve the same problem:
How pattern matching relates to XState:
erikras.com/blog/finite-state-mach...
Related:
forum.rescript-lang.org/t/has-anyo...
Keen to see either this updated for v4 or a whole new article :)
Just updated it for v4! :)
Amazing. Thank you! This library is truly brilliant. I took my team through a little talk about how it works.