Unlock robust, type-safe code in JavaScript and TypeScript with pattern matching type guards, discriminated unions, and exhaustiveness checking. Prevent runtime errors.
JavaScript Pattern Matching Type Guard: A Guide to Type-Safe Pattern Matching
In the world of modern software development, managing complex data structures is a daily challenge. Whether you're handling API responses, managing application state, or processing user events, you often deal with data that can take one of several distinct forms. The traditional approach using nested if-else statements or basic switch cases is often verbose, error-prone, and a breeding ground for runtime errors. What if the compiler could be your safety net, ensuring you've handled every possible scenario?
This is where the power of type-safe pattern matching comes in. By borrowing concepts from functional programming languages like F#, OCaml, and Rust, and leveraging the powerful type system of TypeScript, we can write code that is not only more expressive and readable but also fundamentally safer. This article is a deep dive into how you can achieve robust, type-safe pattern matching in your JavaScript and TypeScript projects, eliminating an entire class of bugs before your code ever runs.
What Exactly Is Pattern Matching?
At its core, pattern matching is a mechanism for checking a value against a series of patterns. It's like a supercharged switch statement. Instead of just checking for equality with simple values (like strings or numbers), pattern matching allows you to check against the structure or shape of your data.
Imagine you're sorting physical mail. You don't just check if the envelope is for "John Doe". You might sort based on different patterns:
- Is it a small, rectangular envelope with a stamp? It's probably a letter.
- Is it a large, padded envelope? It's likely a package.
- Does it have a clear plastic window? It's almost certainly a bill or official correspondence.
Pattern matching in code does the same thing. It lets you write logic that says, "If my data looks like this, do that. If it has this shape, do something else." This declarative style makes your intent much clearer than a complex web of imperative checks.
The Classic Problem: The Unsafe `switch` Statement
Let's start with a common scenario in JavaScript. We're building a graphics application and need to calculate the area of different shapes. Each shape is an object with a `kind` property to tell us what it is.
// Our shape objects
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// PROBLEM: Nothing stops us from accessing shape.sideLength here
// and getting `undefined`. This would result in NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
This pure JavaScript code works, but it's fragile. It suffers from two major problems:
- No Type Safety: Inside the `'circle'` case, the JavaScript runtime has no idea that the `shape` object is guaranteed to have a `radius` property and not a `sideLength`. A simple typo like `shape.raduis` or an incorrect assumption like accessing `shape.width` would result in
undefinedand lead to runtime errors (likeNaNorTypeError). - No Exhaustiveness Checking: What happens if a new developer adds a `Triangle` shape? If they forget to update the `getArea` function, it will simply return `undefined` for triangles, and this bug might go unnoticed until it causes problems in a completely different part of the application. This is a silent failure, the most dangerous kind of bug.
Solution Part 1: The Foundation with TypeScript's Discriminated Unions
To solve these problems, we first need a way to describe our "data that can be one of several things" to the type system. TypeScript's Discriminated Unions (also known as tagged unions or algebraic data types) are the perfect tool for this.
A discriminated union has three components:
- A set of distinct interfaces or types that represent each possible variant.
- A common, literal property (the discriminant) that is present in all variants, like `kind: 'circle'`.
- A union type that combines all the possible variants.
Building a `Shape` Discriminated Union
Let's model our shapes using this pattern:
// 1. Define the interfaces for each variant
interface Circle {
kind: 'circle'; // The discriminant
radius: number;
}
interface Square {
kind: 'square'; // The discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // The discriminant
width: number;
height: number;
}
// 2. Create the union type
type Shape = Circle | Square | Rectangle;
With this `Shape` type, we've told TypeScript that a variable of type `Shape` must be a `Circle`, a `Square`, or a `Rectangle`. It cannot be anything else. This structure is the bedrock of type-safe pattern matching.
Solution Part 2: Type Guards and Compiler-Driven Exhaustiveness
Now that we have our discriminated union, TypeScript's control flow analysis can work its magic. When we use a `switch` statement on the discriminant property (`kind`), TypeScript is smart enough to narrow the type within each `case` block. This acts as a powerful, automatic type guard.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows `shape` is a `Circle` here!
// Accessing shape.sideLength would be a compile-time error.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows `shape` is a `Square` here!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows `shape` is a `Rectangle` here!
return shape.width * shape.height;
}
}
Notice the immediate improvement: inside `case 'circle'`, the type of `shape` is narrowed from `Shape` to `Circle`. If you try to access `shape.sideLength`, your code editor and the TypeScript compiler will immediately flag it as an error. You've eliminated the entire category of runtime errors caused by accessing incorrect properties!
Achieving True Safety with Exhaustiveness Checking
We've solved the type safety problem, but what about the silent failure when we add a new shape? This is where we enforce exhaustiveness checking. We tell the compiler: "You must ensure that I have handled every single possible variant of the `Shape` type."
We can achieve this with a clever trick using the `never` type. The `never` type represents a value that should never occur. We add a `default` case to our `switch` statement that attempts to assign the `shape` to a variable of type `never`.
Let's create a small helper function for this:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
Now, let's update our `getArea` function:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// If we've handled all cases, `shape` will be of type `never` here.
// If not, it will be the unhandled type, causing a compile-time error.
return assertNever(shape);
}
}
At this point, the code compiles perfectly. But now, let's see what happens when we introduce a new `Triangle` shape:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Add the new shape to the union
type Shape = Circle | Square | Rectangle | Triangle;
Instantly, our `getArea` function will show a compile-time error in the `default` case:
Argument of type 'Triangle' is not assignable to parameter of type 'never'.
This is revolutionary! The compiler is now acting as our safety net. It is forcing us to update the `getArea` function to handle the `Triangle` case. The silent runtime bug has become a loud-and-clear compile-time error. By fixing the error, we guarantee our logic is complete.
function getArea(shape: Shape): number { // Now with the fix
switch (shape.kind) {
// ... other cases
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Add the new case
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Once we add the `case 'triangle'`, the `default` case becomes unreachable for any valid `Shape`, the type of `shape` at that point becomes `never`, the error disappears, and our code is once again complete and correct.
Going Beyond `switch`: Declarative Pattern Matching with Libraries
While the `switch` statement with exhaustiveness checking is incredibly powerful, its syntax can still feel a bit verbose. The functional programming world has long favored a more expression-based, declarative approach to pattern matching. Fortunately, the JavaScript ecosystem offers excellent libraries that bring this elegant syntax to TypeScript, with full type safety and exhaustiveness.
One of the most popular and powerful libraries for this is `ts-pattern`.
Refactoring with `ts-pattern`
Let's see how our `getArea` function looks when rewritten with `ts-pattern`:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Ensures all cases are handled, just like our `never` check!
}
This approach offers several advantages:
- Declarative and Expressive: The code reads like a series of rules, clearly stating "when the input matches this pattern, execute this function."
- Type-Safe Callbacks: Notice that in `.with({ kind: 'circle' }, (c) => ...)`, the type of `c` is automatically and correctly inferred as `Circle`. You get full type safety and autocompletion within the callback.
- Built-in Exhaustiveness: The `.exhaustive()` method serves the same purpose as our `assertNever` helper. If you add a new variant to the `Shape` union but forget to add a `.with()` clause for it, `ts-pattern` will produce a compile-time error.
- It's an Expression: The entire `match` block is an expression that returns a value, allowing you to use it directly in `return` statements or variable assignments, which can make code cleaner.
Advanced Capabilities of `ts-pattern`
`ts-pattern` goes far beyond simple discriminant matching. It allows for incredibly powerful and complex patterns.
- Predicate Matching with `.when()`: You can match based on a condition.
- Wildcard Matching with `P.any` and `P.string` etc: Match on the shape of an object without a discriminant.
- Default Case with `.otherwise()`: Provides a clean way to handle any cases not explicitly matched, as an alternative to `.exhaustive()`.
// Handle large squares differently
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Becomes:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* special logic for large squares */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Match any object that has a numeric `radius` property
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Practical Use Cases for a Global Audience
This pattern is not just for geometric shapes. It is incredibly useful in many real-world programming scenarios that developers across the globe face daily.
1. Handling API Request States
A common task is fetching data from an API. The state of this request can typically be one of several possibilities: initial, loading, success, or error. A discriminated union is perfect for modeling this.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// In your UI component (e.g., React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Welcome! Click a button to load your profile.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
With this pattern, it's impossible to accidentally render a user profile when the state is still loading, or to try accessing `state.data` when the status is `error`. The compiler guarantees the logical consistency of your UI.
2. State Management (e.g., Redux, Zustand)
In state management, you dispatch actions to update the application state. These actions are a classic use case for discriminated unions.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` is correctly typed here!
// ... logic to add item
return { ...state, /* updated items */ };
case 'REMOVE_ITEM':
// ... logic to remove item
return { ...state, /* updated items */ };
// ... and so on
default:
return assertNever(action);
}
}
When a new action type is added to the `CartAction` union, the `cartReducer` will fail to compile until the new action is handled, preventing you from forgetting to implement its logic.
3. Processing Events
Whether handling WebSocket events from a server or user interaction events in a complex application, pattern matching provides a clean, scalable way to route events to the correct handlers.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`User ${e.userId} logged in.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Unhandled event: ${e.event}`));
}
The Benefits Summarized
- Bulletproof Type Safety: You eliminate an entire class of runtime errors related to incorrect data shapes (e.g.,
Cannot read properties of undefined). - Clarity and Readability: The declarative nature of pattern matching makes the programmer's intent obvious, leading to code that is easier to read and understand.
- Guaranteed Completeness: Exhaustiveness checking turns the compiler into a vigilant partner that ensures you've handled every possible data variant.
- Effortless Refactoring: Adding new variants to your data models becomes a safe, guided process. The compiler will point out every single location in your codebase that needs to be updated.
- Reduced Boilerplate: Libraries like `ts-pattern` provide a concise, powerful, and elegant syntax that is often much cleaner than traditional control flow statements.
Conclusion: Embrace Compile-Time Confidence
Moving from traditional, unsafe control flow structures to type-safe pattern matching is a paradigm shift. It's about moving checks from runtime, where they manifest as bugs for your users, to compile-time, where they appear as helpful errors for you, the developer. By combining TypeScript's discriminated unions with the power of exhaustiveness checking—either through a manual `never` assertion or a library like `ts-pattern`—you can build applications that are more robust, maintainable, and resilient to change.
The next time you find yourself writing a long `if-else if-else` chain or a `switch` statement on a string property, take a moment to consider if you can model your data as a discriminated union. Make the investment in type safety. Your future self, and your global user base, will thank you for the stability and reliability it brings to your software.