Delve into the intricate world of pattern matching and discover its profound impact on code efficiency and expressiveness.
homeowners communities managed
of property managers in Spain using it
reduction of banking cost for communities
Industry
Use case
Location
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).
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" | |
)} | |
</> | |
); | |
}; |
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.
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); | |
} | |
})()} | |
</> | |
); | |
}; |
Aaaah, much better already!
However, a few problems still remain:
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()} | |
</> | |
); | |
}; |
As you can see:
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(); |
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 | |
} | |
} | |
} | |
} |
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[] }>; | |
}; |
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(); | |
}); |
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}"`); | |
}); | |
}); |
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!
Summary
Customer stories