Pattern Matching
Pattern matching branches on the concrete shape of a value and, at the same time, binds the data inside that shape to local names. Instead of separately checking a tag and then manually digging payload fields out on the next line, one construct does both jobs. In functional programming this matters because values are often modeled as ADTs. Once the data is designed as distinct variants, pattern matching becomes the natural way to consume it. It is less a fancy control structure than the control-flow counterpart to value-oriented modeling.
βΆArchitecture Diagram
π ProcessDiagram preview
The interactive diagram loads after the page becomes ready.
Dashed line animations indicate the flow direction of data or requests
Code that first checks a tag and then, in a separate step, reaches into payload fields is easy to get wrong. One branch can be forgotten, a payload can be read under the wrong condition, or the connection between the tag and the available fields can drift over time. As more data is modeled with variants, those mistakes multiply. The program needs a construct that keeps the branch condition and the payload access tied together and, ideally, confirms that every valid case has been handled.
Pattern matching became a defining feature of ML-family languages, Haskell, Erlang, Scala, Rust, and other languages influenced by functional programming. The motivation was straightforward: once data is modeled precisely, the language should help you consume it precisely too. That is why modern discriminated unions in TypeScript or enums with payloads in Rust feel so powerful only when paired with a matching construct. The model and the branching form a system together.
Pattern matching usually works in three steps. First, it inspects which variant the value actually is. Second, if that variant carries payload data, it binds those pieces to local variables. Third, it runs the branch associated with that pattern. In languages with exhaustiveness checking, the compiler also verifies that every possible variant has a branch. The result is a construct that combines branching and destructuring in one move. That is what makes it cleaner than a tag check followed by manual field access.
Branching on the shape of a Result
type Result<T, E> =
| { kind: "ok"; value: T }
| { kind: "err"; error: E };
function assertNever(value: never): never {
throw new Error(`Unhandled branch: ${JSON.stringify(value)}`);
}
function toMessage(result: Result<{ name: string }, string>): string {
switch (result.kind) {
case "ok":
return `Loaded ${result.value.name}`;
case "err":
return `Error: ${result.error}`;
default:
return assertNever(result);
}
}Once the code branches on `kind`, each case exposes a different safe payload. The `never` check in `default` reveals when a new variant has been added but not handled.
Splitting evaluator logic by AST node type
type Expr =
| { type: "number"; value: number }
| { type: "add"; left: Expr; right: Expr }
| { type: "negate"; value: Expr };
function assertNever(value: never): never {
throw new Error(`Unhandled node: ${JSON.stringify(value)}`);
}
function evaluate(expr: Expr): number {
switch (expr.type) {
case "number":
return expr.value;
case "add":
return evaluate(expr.left) + evaluate(expr.right);
case "negate":
return -evaluate(expr.value);
default:
return assertNever(expr);
}
}The branch condition and the available fields stay tied together, so `left/right` are only read in the `add` branch and numeric values are only read where that shape actually exists.
Pattern matching and ADTs are complementary, not interchangeable. ADTs define the legal shapes of data; pattern matching decides what the program does for each legal shape. It also differs from ordinary if/else chains or basic switch statements. If the logic is a simple boolean or range check, if/else is often enough. Pattern matching earns its keep when the branch condition and the payload structure belong together, because that is where it keeps the code aligned with the model.
Pattern matching is the standard tool for consuming Option, Result, async UI state, syntax trees, protocol messages, and other multi-variant values. In everyday code it shines when the same pattern repeats: inspect the variant, unpack the payload, and do something specific for that shape. If you notice those three steps happening manually in several places, pattern matching is usually the missing abstraction. It reduces forgotten branches, makes each branch's available data obvious, and keeps control flow anchored to the data model instead of to conventions in the developer's head.