Conceptly
← All Concepts
πŸ”€

Pattern Matching

FoundationsBranching by shape while unpacking data

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

πŸ”„ Process

Diagram preview

The interactive diagram loads after the page becomes ready.

Dashed line animations indicate the flow direction of data or requests

Why do you need it?

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.

Why did this approach emerge?

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.

How does it work inside?

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.

In Code

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.

Boundaries & Distinctions

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.

When should you use it?

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.

Result handling -- continuing on success and rendering or logging the error branch explicitlyUI rendering -- choosing different components for Loading, Empty, Success, or Error statesAST interpretation -- running different logic for each node variantOptional values -- handling Some and None without forgetting one branch