Pattern Matching with TypeScript
TypeScript does not have any pattern matching functionality built in. This article shows several ways how you can replicate the core of a simple pattern matcher using a few simple structures and functions within TypeScript.
Resulting code will have improved maintainability and better runtime type safety when done right.
What is Pattern Matching?
Pattern matching is a fundamental and powerful building block to many functional programming languages like Haskell or Scala.
We will not be able to replicate pattern matching on the level of sophistication as those languages provide. Though we can reuse the fundamental approach of pattern matching as inspiration to build something helpful in TypeScript.
Essentially a pattern can be defined as a thing describing one or more cases. Each case itself represents a specific behavior which has to be applied once the case matches.
You might think: “Hey! That sounds like a switch
statement to me!”. And you are right indeed:
Match with Switch Statement
function matchNumber(n: number): string {
switch (n) {
case 1:
return 'one';
case 2:
return 'two';
case 3:
return 'three';
default:
return `${n}`;
}
}
function randomNumber(): number {
return Math.floor(Math.random() * (10 - 1) + 1); // Random number 1...10
}
const result = matchNumber(randomNumber()); // result === One, Two, Three or 4...10
We can use a switch
statement to map number
s to its desired string
representation.
Doing so is straightforward, but we can make out flaws for matchNumber
:
- The behavior for each case is baked into the
matchNumber
function. You have to reimplement the completeswitch
block if you want to map to something else than astring
, for example, aboolean
. - Functional requirements can be misinterpreted and behavior for a case gets lost. What about
4
? What if a developer forgets aboutdefault
? The possibility of bugs multiplies easily when theswitch
is reimplemented several times as described under point 1.
These flaws can be translated into a set of characteristics for a solution:
- Separate matching a specific case from its behavior
- Make reuse of matcher simple to prevent bugs through duplicated code
- Implement matcher once for different types
Separation of Concerns
Let’s define an interface containing functions for each case we want to be able to match. This allows separating behavior from actual matcher logic later.
interface NumberPattern {
One: () => string;
Two: () => string;
Three: () => string;
Other: (n: number) => string;
}
Having NumberPattern
, we can rebuild matchNumber
:
function matchNumber(p: NumberPattern): (n: number) => string {
return (n: number): string => {
switch (n) {
case 1:
return p.One();
case 2:
return p.Two();
case 3:
return p.Three();
default:
return p.Other(n);
}
};
}
The new implementation consumes a NumberPattern
. It returns a function which uses our switch
block from before with an important difference: It does no longer map a number
to a string
on its own, it delegates that job to the pattern initially given to matchNumber
.
Applying NumberPattern
and the new matchNumber
to the task from the previous section results in the following code:
const match = matchNumber({
One: () => 'One',
Two: () => 'Two',
Three: () => 'Three',
Other: (n) => `${n}`
});
const result = match(randomNumber()); // result === One, Two, Three or 4...10
We clearly separated case behaviors from the matcher. That first point can be ticked off. Does it further duplicating code and improve maintainability of the matcher?
const matchGerman = matchNumber({
One: () => 'Eins',
Two: () => 'Zwei',
Three: () => 'Drei',
Other: (n) => `${n}`
});
const result = matchGerman(randomNumber()); // result === Eins, Zwei, Drei or 4...10
Another tick! Because we have split concerns by introducing NumberPattern
, changing behavior without reimplementing the underlying matcher logic is straightforward.
Truly Reusable
Map a number
to something different than a string
still needs reimplementation of matchNumber
. Can we solve this without doing so for each target type over and over again? Sure! Generics provide an elegant solution:
interface NumberPattern<T> {
One: () => T;
Two: () => T;
Three: () => T;
Other: (n: number) => T;
}
function matchNumber<T>(p: NumberPattern<T>): (n: number) => T {
return (n: number): T => {
// ...
};
}
Introducing the generic type parameter T
makes NumberPattern
and matchNumber
truly reusable: It can map a number
to any other type now. For example a boolean
:
const isLargerThanThree = matchNumber({
One: () => false,
Two: () => false,
Three: () => false,
Other: n => n > 3
});
const is100Larger = isLargerThanThree(100); // is100Larger === true
const is1Larger = isLargerThanThree(1); // is1Larger === false
This fulfills the last point in our requirement list to implement the matcher once for different types. The final example will probably never make it to production code but it demonstrates the basic mechanic how a pattern and a corresponding matcher can be implemented in TypeScript.
Match Union Types
Union types are a convenient way to model more sophisticated types. Knowing what specific type you are handling can be tedious though:
type Argument = string | boolean;
const a = 'Hello World';
if (typeof a === 'string') {
// do string stuff
} else if (typeof a === 'boolean') {
// do boolean stuff
}
Let’s assume I am lazy and desperately need that if
block somewhere else too. I simply copy-and-paste the block and introduce successfully the first part of maintenance hell:
A new requirement wants me to allow number
s as an argument in the application, so I modify the type definition of Argument
accordingly and update one of the if
blocks (because I already forgot about its twin):
type Argument = string | boolean | number;
// ...
} else if (typeof a === 'number') {
// do number stuff
}
The duplicated code with different type handling for Argument
bears huge potential for runtime errors and undiscovered bugs.
With the pattern matcher from the section before we already know a handy tool to defuse this situation. The ArgumentPattern
describes all possible cases when handling an Argument
and the matchArgument
matcher encapsulates the cumbersome code and makes it reusable:
interface ArgumentPattern<T> {
String: (s: string) => T;
Boolean: (b: boolean) => T;
Number: (n: number) => T;
}
function matchArgument<T>(p: ArgumentPattern<T>): (a: Argument) => T {
return (a: Argument): T => {
if (typeof a === 'string') {
return p.String(a);
} else if (typeof a === 'boolean') {
return p.Boolean(a);
} else if (typeof a === 'number') {
return p.Number(a);
}
throw new Error(`matchArgument: Could not match type ${typeof a}`);
};
}
const aString = 'Hello World';
const result = matchArgument({
String: (s) => console.log(`A string: ${s}`),
Boolean: (b) => console.log(`A boolean: ${b}`),
Number: (n) => console.log(`A number: ${n}`)
})(aString); // result === "A string: Hello World"
The big advantage of this solution plays once I have to modify the Argument
type again: I Simply adapt ArgumentPattern
accordingly and TypeScript will light up all code occurrences where action needs to be taken. A consistent evaluation of a union type becomes much easier this way.
Real Life Problem Domain
Following final example takes techniques introduced earlier and applies them to a more real live alike problem domain. An imaginative cash register application provides different ways how a customer can pay his bill. This requirement is modeled using the Payment
type and two specializations CreditCardPayment
and CashPayment
. A PaymentPattern
interface is implemented along with those types:
interface PaymentPattern<T> {
CreditCard: (card: CreditCardPayment) => T;
Cash: (cash: CashPayment) => T;
}
interface PaymentMatcher {
match<T>(p: PaymentPattern<T>): T;
}
abstract class Payment implements PaymentMatcher {
constructor(public readonly amount: number) {}
abstract match<T>(p: PaymentPattern<T>): T;
}
class CreditCardPayment extends Payment {
constructor(amount: number, public readonly fee: number) {
super(amount);
}
match<T>(p: PaymentPattern<T>): T {
return p.CreditCard(this);
}
}
class CashPayment extends Payment {
constructor(amount: number, public readonly discount: number) {
super(amount);
}
match<T>(p: PaymentPattern<T>): T {
return p.Cash(this);
}
}
You may notice the absence of a distinct matchPayment
function when comparing to former examples. This slightly different approach applies a variation of the visitor pattern by using a PaymentMatcher
interface and a pinch of polymorphism magic instead.
Doing so prevents a set of cumbersome and potentially harmful instanceof
compares by baking PaymentMatcher
into the abstract Payment
base type. Each specialized payment implements PaymentMatcher.match
then on its own.
The matcher function is called on the concrete type now. calculatePaymentAmount
showcases this by applying different calculation strategies depending on what kind of payment is processed:
function calculatePaymentAmount(payment: Payment) {
return payment.match({
CreditCard: (card) => card.amount + (card.amount * card.fee),
Cash: (cash) => cash.amount - cash.discount
});
}
const creditCardPayment = new CreditCardPayment(100, 0.02);
const creditCardAmount = calculatePaymentAmount(creditCardPayment);
// creditCardAmount === 102
const cashPayment = new CashPayment(100, 2);
const cashAmount = calculatePaymentAmount(cashPayment);
// cashPayment === 98
An obvious extension might be the introduction of an additional payment type or the change of an existing calculation strategy. Each of those are nicely secured by compile time checks which help to minimize the potential for new bugs.
Conclusion
The presented solution to bring pattern matching to TypeScript based applications is a powerful way to keep growing code bases better maintainable. It is a tool to keep code duplication low and keep conditions separated from actual behavior. More readable code is an additional side effect.
I do work with these paradigms for quite some time up to today. After writing this article one thing is even more clear to me than before: To replicate pattern matching for a language like TypeScript means to introduce a lot of boilerplate code. And indeed this boilerplate can be overkill for small, simple applications.
My personal experience proofs for myself that exactly this boilerplate can help to mitigate risks and potential bugs as a code base grows, developers hop on and off and requirements increase in their complexity.
Similar to other ways of structuring code, pattern matching is no silver bullet either. Have it in your toolbox and apply it with care when the situation seems reasonable.
Repository, Discussion & Credits
All code examples used in this article are available along with a Jest test suite in a complementary Github repository: https://github.com/swissmanu/pattern-matching-with-typescript
There is a thread on Hacker News with a discussion triggered by this article available: Pattern Matching with TypeScript @ HN
Further, I would like to thank @dbrack and @mweibel for proofreading and reviewing this article during its making. Thank you, guys! ❤️
Revision Notes
05.07.2017:
- Initial publish 🎉
10.07.2017:
- Section “Real Life Problem Domain”: Add reference to visitor pattern in
- Section “Repository, Credits & Discussion”: Added link to HN discussion thread