Swan adds €42M to its Series B to expand embedded banking across Europe. Read more here.
Blog

Unraveling the magic of Pattern Matching

Delve into the intricate world of pattern matching and discover its profound impact on code efficiency and expressiveness. We’ll unravel the underlying principles and practical applications that make it a powerful tool for developers.

Mathieu Acthernoene
July 31, 2023

For frontend development at Swan, we use TypeScript, a typed superset of JavaScript that, in our opinion, needs no introduction.

The downside of using TypeScript is that it deprives us of several paradigms that are absent in JS and that we may be accustomed to using when we worked with other languages in the past. One of the biggest shortcomings for us is the lack of pattern matching (it's currently being considered, but still in draft).

Pattern…what? And what does it solve?

Yes, pattern matching.

Let's imagine that we want to describe the loading state of a request, which can be uninitialized, in progress, successfully completed, or completed with an error.

Naively, we could write it as follows:

type State = {
loading: boolean;
data?: User;
error?: number;
};
const App = () => {
const [state, setState] = useState<State>({
loading: false,
data: undefined,
error: undefined,
});
const getRandomUser = () => {
setState({
loading: true,
data: undefined,
error: undefined,
});
query().then(
(data) =>
setState({
loading: false,
data,
error: undefined,
}),
(error) =>
setState({
loading: false,
data: undefined,
error,
})
);
};
return (
<>
<button onClick={getRandomUser} disabled={state.loading}>
{!state.loading && state.data == null && state.error == null
? "Get a random user"
: state.loading
? "Loading"
: state.error
? "Try again"
: "Get another one"}
</button>
{state.loading ||
(state.data == null && state.error == null) ? null : state.error !=
null ? (
"An error occurred"
) : state.data != null ? (
<UserCard data={state.data} />
) : (
"No result was received"
)}
</>
);
};
view raw 1.tsx hosted with ❤ by GitHub

As you can see, this code is hard to read and maintain, and is also prone to errors: what happens if a setUser((prevState) => ({ ...prevState, user: newRandomUser })) sneaks into your code? You could end up with the user state similar to { isLoading: false, data: newRandomUser, error: previousUserError }, an impossible case for our UI.

Let’s try discriminated unions

Fortunately, TypeScript offers us a solution to this problem with discriminated unions: unions of object types with a common property (the discriminant) allowing us to determine which kind of shape our value has.

Let's refactor our previous code using discriminated unions:

const exhaustive = (_: never): never => {
throw new Error("Impossible case");
};
type State =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; error: Error }
| { kind: "success"; data: User };
const App = () => {
const [state, setState] = useState<State>({ kind: "idle" });
const getRandomUser = () => {
setState({ kind: "loading" });
query().then(
(data) => setState({ kind: "success", data }),
(error) => setState({ kind: "error", error })
);
};
return (
<>
<button onClick={getRandomUser} disabled={state.kind === "loading"}>
{(() => {
switch (state.kind) {
case "idle":
return "Get a random person";
case "loading":
return "Loading";
case "error":
return "Try again";
case "success":
return "Get a random person";
default:
return exhaustive(state);
}
})()}
</button>
{(() => {
switch (state.kind) {
case "idle":
case "loading":
return null;
case "error":
return "An error occurred";
case "success":
return <UserCard data={state.data} />;
default:
return exhaustive(state);
}
})()}
</>
);
};
view raw 2.tsx hosted with ❤ by GitHub

Aaaah, much better already!

We’re close, but not there yet

However, a few problems still remain:

  • Since switch is not an expression (it doesn't return a value), we need to wrap each condition in an IIFE (an alternative would be to continue using nested ternaries).
  • We need to introduce a new helper function, exhaustive, to ensure that we handle all cases properly (this way, TypeScript will warn us if the union evolve over time and we miss a new case).
  • It works for simple cases, but if we want to add another condition, let's say, to display something else if the user is disabled, the condition code would become nested again.

Introducing ts-pattern: handling complex conditions as an expression

ts-pattern is a pattern-matching library for TypeScript developed by Gabriel Vergnaud. It allows you to compare an input value against different patterns, execute a function if it matches, and return the result. Consider this example:

import { match } from "ts-pattern";
type State =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; error: Error }
| { kind: "success"; data: User };
const App = () => {
const [state, setState] = useState<State>({ kind: "idle" });
const getRandomUser = () => {
setState({ kind: "loading" });
query().then(
(data) => setState({ kind: "success", data }),
(error) => setState({ kind: "error", error })
);
};
return (
<>
<button onClick={getRandomUser} disabled={state.kind === "loading"}>
{match(state)
.with({ kind: "idle" }, () => "Get a random person")
.with({ kind: "loading" }, () => "Loading")
.with({ kind: "error" }, () => "Try again")
.with({ kind: "success" }, () => "Get a random person")
.exhaustive()}
</button>
{match(state)
.with({ kind: "idle" }, { kind: "loading" }, () => null)
.with({ kind: "error" }, () => "An error occurred")
.with({ kind: "success" }, ({ data }) => <UserCard data={data} />)
.exhaustive()}
</>
);
};
view raw 3.tsx hosted with ❤ by GitHub

As you can see:

  • match() is an expression; it returns a value. There’s no need to wrap it in an IIFE.
  • Using .exhaustive() removes the need for our custom exhaustive helper function.
  • match() can also solve the “another condition” problem elegantly by adding a new branch:
match(state)
.with({ kind: "idle" }, { kind: "loading" }, () => null)
.with({ kind: "error" }, () => "An error occurred")
// add a branch where we check if the user is disabled:
.with({ kind: "success", data: { disabled: true } }, () => "Disabled")
.with({ kind: "success" }, ({ data }) => <UserCard data={data} />)
.exhaustive();
view raw 4.tsx hosted with ❤ by GitHub

Pattern matching and GraphQL, a match() made in heaven

Swan exposes a GraphQL API, so it’s quite easy to leverage pattern matching to consume our API, and it’s not only for the frontend!

Let's start by generating an SDK using GraphQL Code Generator and write a GraphQL operation:

mutation AddCard($input: AddCardInput!) {
addCard(input: $input) {
__typename
... on AddCardSuccessPayload {
card {
id
}
}
... on Rejection {
message
}
... on ValidationRejection {
fields {
code
path
}
}
}
}
view raw 5.gql hosted with ❤ by GitHub

The generated return type is a discriminated union, similar to the one we wrote before:

type Result =
| { __typename: "AddCardSuccessPayload"; card: { id: string } }
| { __typename: "AccountMembershipNotAllowedRejection"; message: string }
| { __typename: "BadAccountStatusRejection"; message: string }
| { __typename: "CardProductDisabledRejection"; message: string }
| { __typename: "CardProductSuspendedRejection"; message: string }
| { __typename: "EnabledCardDesignNotFoundRejection"; message: string }
| { __typename: "ForbiddenRejection"; message: string }
| { __typename: "MissingMandatoryFieldRejection"; message: string }
| {
__typename: "ValidationRejection";
message: string;
fields: Array<{ code: ValidationFieldErrorCode; path: string[] }>;
};
view raw 6.tsx hosted with ❤ by GitHub

So we can consume it like this:

const swanSdk = getSdk(
new GraphQLClient("https://api.swan.io/live-partner/graphql")
);
swanSdk
.AddCard({
input: {
accountMembershipId: myAccountMembershipId,
consentRedirectUrl: myConsentRedirectUrl,
international: true,
eCommerce: true,
nonMainCurrencyTransactions: true,
withdrawal: true,
},
})
.then(({ addCard: data }) => {
match(data)
.with({ __typename: "AddCardSuccessPayload" }, ({ card }) => {
// Note that `card` is only available here!
// …
})
.with({ __typename: "ValidationRejection" }, ({ fields }) => {
// …
})
.with(
{ __typename: "AccountMembershipNotAllowedRejection" },
{ __typename: "BadAccountStatusRejection" },
{ __typename: "CardProductDisabledRejection" },
{ __typename: "CardProductSuspendedRejection" },
{ __typename: "EnabledCardDesignNotFoundRejection" },
{ __typename: "ForbiddenRejection" },
{ __typename: "MissingMandatoryFieldRejection" },
({ message }) => {
console.error(`rejected with "${message}"`);
}
)
.exhaustive();
});
view raw 7.tsx hosted with ❤ by GitHub

Since the matching is exhaustive, you will be notified of any new rejections added in the API updates and you will have to update your code to handle them accordingly. If you wish to ignore this, you can use .otherwise() instead:

const swanSdk = getSdk(
new GraphQLClient("https://api.swan.io/live-partner/graphql")
);
swanSdk
.AddCard({
input: {
accountMembershipId: myAccountMembershipId,
consentRedirectUrl: myConsentRedirectUrl,
international: true,
eCommerce: true,
nonMainCurrencyTransactions: true,
withdrawal: true,
},
})
.then(({ addCard: data }) => {
match(data)
.with({ __typename: "AddCardSuccessPayload" }, ({ card }) => {
// …
})
.with({ __typename: "ValidationRejection" }, ({ fields }) => {
// …
})
.otherwise(({ message }) => {
console.error(`rejected with "${message}"`);
});
});
view raw 8.tsx hosted with ❤ by GitHub

Of course, we are only scratching the surface of what is possible. To delve deeper, you can visit the documentation of ts-pattern, explore our frontend codebase (where we even use pattern matching to handle results returned by our router) or clone and experiment with the latest example repository.

Have fun!

Mathieu Acthernoene
July 31, 2023
Share article
Contents
Elevate your company's product and create new revenue streams with banking features.
Talk to a fintech expert.

Related Blog Articles

No related posts yet.
More articles coming soon.