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.
Pattern matching is a fundamental and powerful building block to many functional programming languages like Haskell or Scala.
TODO: Disclaimer: Language-Level Pattern matchers go further than what we build here… Though its a nice abstraction pattern to improve code maintainability
A pattern can contain one or more cases. Each case describes behaviour 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:
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 } console.log(matchNumber(randomNumber()));We can use a switch statement to map numbers to its desired string representation.
Doing so is straight forward, though we can make out flaws for matchNumber:
- The behaviour for each case is baked into the
matchNumberfunction. If you want to mapnumbers to, lets saybooleans, you have to reimplement the completeswitchblock in another function. - Requirements can be misinterpreted and behaviour for a case gets lost. What about
4? What if a developer forgets aboutdefault? The possibility of bugs multiplies easily when theswitchis reimplemented several times as described under point 1.
Trying to solve these flaws outlines requirements for an improved solution:
- Separate matching a specific case from its behaviour
- Make reuse of matcher simple to prevent bugs through duplicated code
- Implement matcher once for different types
Lets define an interface containing functions for each case we want be able to match. This allows to separate behaviour 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}` }); console.log(match(randomNumber()))We clearly separated case behaviours from the matcher. That first point can be ticked off. Does it further us from duplicating code and improve maintainability of the matcher?
const matchGerman = matchNumber({ One: () => 'Eins', Two: () => 'Zwei', Three: () => 'Drei', Other: (n) => `${n}` }); console.log(matchGerman(randomNumber()))Another tick! Because we have split concerns by introducing NumberPattern, changing behavior without reimplementing the underlying matcher logic is straight forward.
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 }); console.log(isLargerThanThree(100)); console.log(isLargerThanThree(1));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, though it demonstrates the basic mechanic how a pattern and a corresponding matcher can be implemented in TypeScript.