Unlocking Pattern Matching in TypeScript: A Complete Guide with Examples
When working with TypeScript, especially when dealing with complex data structures or conditional logic, a common request is for a feature called pattern matching. Pattern matching allows us to write concise, expressive, and type-safe code to handle different types and values within a single expression. While TypeScript doesn’t have built-in pattern matching like Rust or Haskell, it’s possible to emulate similar functionality using tagged unions, type guards, and functional patterns. Here, we’ll dive into practical ways to achieve pattern matching in TypeScript and explore important considerations.
1. Pattern Matching with Tagged Unions
Tagged unions, also known as discriminated unions, are one of the most effective ways to replicate pattern matching in TypeScript. A tagged union in TypeScript is a union of object types with a common discriminant property—typically a type
or kind
field—that helps TypeScript determine the specific shape of the union in different contexts. Tagged unions make switch-case statements more expressive and type-safe.
For example, consider a scenario where you want to calculate the area of different shapes:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default:
const _exhaustiveCheck: never = shape; // Ensures all cases are handled
throw new Error(`Unhandled case: ${_exhaustiveCheck}`);
}
}
In the getArea
function, the discriminant property kind
helps TypeScript narrow down the possible shapes, making it easier to handle each shape specifically and safely. Note the _exhaustiveCheck
variable in the default
case: this is a type-safe trick that lets TypeScript verify that all cases have been handled. If a new shape is added without a corresponding case, TypeScript will throw a compile-time error.
2. Using Type Guards for Pattern Matching
When working with more complex logic that doesn’t fit well into a switch-case structure, type guards allow for custom pattern matching while keeping code clean and readable. Type guards can also be beneficial when there are nested properties that need to be checked in order to determine the exact type of an object.
Here’s how to use type guards with a similar shape example:
type Animal =
| { type: "dog"; bark: () => string }
| { type: "cat"; meow: () => string }
| { type: "bird"; chirp: () => string };
function makeSound(animal: Animal): string {
if (animal.type === "dog") {
return animal.bark();
} else if (animal.type === "cat") {
return animal.meow();
} else if (animal.type === "bird") {
return animal.chirp();
} else {
const _exhaustiveCheck: never = animal;
throw new Error(`Unhandled animal type: ${_exhaustiveCheck}`);
}
}
In this case, the type
property is used to check each case within the makeSound
function. Each branch narrows down the type, and TypeScript’s type inference fills in the rest. Again, _exhaustiveCheck
ensures that all cases are accounted for, creating a fail-safe against forgotten types.
3. Functional Pattern Matching with a Helper Function
Sometimes, using functions as pattern handlers can simplify logic by allowing specific functions to handle each case. This is particularly helpful if the function associated with each pattern has complex logic. We can create a pattern-matching function that takes handlers for each case, letting the user specify what to do based on the type of object they have.
Here’s an example with shapes:
type ShapePattern<T> = {
circle: (radius: number) => T;
square: (side: number) => T;
rectangle: (width: number, height: number) => T;
};
function matchShape<T>(shape: Shape, pattern: ShapePattern<T>): T {
switch (shape.kind) {
case "circle":
return pattern.circle(shape.radius);
case "square":
return pattern.square(shape.side);
case "rectangle":
return pattern.rectangle(shape.width, shape.height);
}
}
// Usage
const shape: Shape = { kind: "circle", radius: 5 };
const area = matchShape(shape, {
circle: (radius) => Math.PI * radius ** 2,
square: (side) => side ** 2,
rectangle: (width, height) => width * height,
});
This approach provides more flexibility and avoids deeply nested if-else statements. It also opens up the possibility of adding default behavior for cases that don’t need handling or using optional chaining for certain pattern cases, which can improve readability and maintainability.
Additional Considerations for Pattern Matching in TypeScript
While these techniques allow for effective pattern matching, here are a few considerations and potential improvements:
- Exhaustiveness Checking: Always check for exhaustiveness in your pattern-matching implementations. Using
_exhaustiveCheck: never
is an effective way to ensure all cases are handled. - Error Handling: Consider implementing custom error handling within each pattern. If certain patterns are more likely to produce errors, handle these within the pattern-matching logic rather than relying on outside error handling.
- Complex Nesting: For more complex data types or deeply nested structures, consider using recursive functions or tree traversal techniques to match patterns. This approach is especially useful for dealing with nested JSON structures or data from APIs.
- Functional Composition: If your patterns follow a specific functional structure, it may be worth defining higher-order functions or using a functional programming library like fp-ts to extend the pattern-matching capability even further.
- Type Performance: Excessive use of unions and type guards in highly dynamic applications may impact compile times. Testing complex implementations for performance impacts on both the TypeScript compiler and runtime behavior can be beneficial, especially in large codebases.
Finally
While TypeScript doesn’t support native pattern matching, we can achieve similar functionality using tagged unions, type guards, and functional pattern handlers. Each of these methods comes with its own advantages, and understanding when to use each is key. Tagged unions are ideal for simpler cases, type guards provide flexibility for complex logic, and functional handlers open up even more possibilities by allowing for extensible, declarative pattern matching.