Skip to content

Pattern Matching

Pattern matching is a powerful control flow mechanism that checks a value against a series of patterns and executes the code associated with the first matching pattern. Think of it as a switch statement that can match on structure, type, and nested data — not just equality. Pattern matching is a cornerstone of functional programming and is increasingly being adopted by mainstream languages.


Why Pattern Matching?

Consider how you typically handle different cases without pattern matching:

Without pattern matching (if/else chains):
if isinstance(shape, Circle):
area = pi * shape.radius ** 2
elif isinstance(shape, Rectangle):
area = shape.width * shape.height
elif isinstance(shape, Triangle):
area = 0.5 * shape.base * shape.height
else:
raise ValueError("Unknown shape")
Problems:
- Easy to forget a case (no exhaustiveness check)
- Cannot destructure in the condition
- Verbose and error-prone
- Adding a new shape requires finding all if/elif chains
With pattern matching:
match shape:
case Circle(radius=r):
area = pi * r ** 2
case Rectangle(width=w, height=h):
area = w * h
case Triangle(base=b, height=h):
area = 0.5 * b * h
Benefits:
- Concise and readable
- Destructures values inline
- Compiler/linter can warn about missing cases
- Adding a new shape triggers warnings everywhere

Pattern Matching Across Languages

Language Support

LanguageFeatureSinceExhaustive?
Pythonmatch/case3.10 (2021)No (but linters can check)
JavaScriptTC39 proposal (Stage 1)ProposedN/A
Javaswitch expressions + patterns21 (2023)Yes (sealed types)
Rustmatch1.0 (2015)Yes (enforced by compiler)
Scalamatch1.0Yes (sealed traits)
Haskellcase/function patterns1.0Yes (with warnings)
Elixircase/function heads1.0Yes (with guards)
C#switch expressions + patterns8.0 (2019)Partial
Kotlinwhen1.0Yes (sealed classes)

Basic Pattern Matching

# Python 3.10+ structural pattern matching
# Literal patterns
def describe_http_status(status: int) -> str:
match status:
case 200:
return "OK"
case 301:
return "Moved Permanently"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
case _:
return f"Unknown status: {status}"
print(describe_http_status(404)) # Not Found
# OR patterns
def classify_char(c: str) -> str:
match c:
case 'a' | 'e' | 'i' | 'o' | 'u':
return "vowel"
case c if c.isalpha():
return "consonant"
case c if c.isdigit():
return "digit"
case _:
return "other"
# Guard clauses
def classify_number(n: int) -> str:
match n:
case n if n < 0:
return "negative"
case 0:
return "zero"
case n if n % 2 == 0:
return "positive even"
case _:
return "positive odd"

Destructuring

Pattern matching often includes destructuring: extracting values from data structures as part of the match.

# Sequence destructuring
def process_command(command: list) -> str:
match command:
case ["quit"]:
return "Exiting..."
case ["greet", name]:
return f"Hello, {name}!"
case ["move", x, y]:
return f"Moving to ({x}, {y})"
case ["move", x, y, z]:
return f"Moving to ({x}, {y}, {z})"
case ["say", *words]:
return " ".join(words)
case _:
return "Unknown command"
print(process_command(["greet", "Alice"])) # Hello, Alice!
print(process_command(["move", 3, 4])) # Moving to (3, 4)
print(process_command(["say", "hi", "there"])) # hi there
# Dictionary destructuring
def process_event(event: dict) -> str:
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "keypress", "key": key}:
return f"Key pressed: {key}"
case {"type": "scroll", "direction": "up"}:
return "Scrolling up"
case {"type": "scroll", "direction": "down"}:
return "Scrolling down"
case _:
return "Unknown event"
print(process_event({"type": "click", "x": 100, "y": 200}))
# Click at (100, 200)
# Class destructuring
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
import math
def area(shape) -> float:
match shape:
case Circle(radius=r):
return math.pi * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return 0.5 * b * h
case _:
raise ValueError(f"Unknown shape: {shape}")
print(area(Circle(5))) # 78.54
print(area(Rectangle(3, 4))) # 12.0
print(area(Triangle(6, 8))) # 24.0

Algebraic Data Types

Algebraic Data Types (ADTs) are types composed from simpler types using two operations:

Sum Types (Tagged Unions)

A sum type is a type that can be one of several variants. Each variant may carry different data.

Sum type: Shape = Circle(radius) | Rectangle(width, height) | Triangle(base, height)
A Shape is EITHER a Circle OR a Rectangle OR a Triangle.
Never more than one at a time.
Circle(5) -- valid Shape
Rectangle(3, 4) -- valid Shape
Circle + Rectangle -- impossible

Product Types (Records/Tuples)

A product type combines multiple fields together. A product type has all of its fields simultaneously.

Product type: Point = (x: float, y: float)
A Point has BOTH an x AND a y coordinate.

Why “Algebraic”?

The name comes from the number of possible values:

Sum type: |A + B| = |A| + |B| (number of variants adds up)
Product type: |A * B| = |A| * |B| (combinations multiply)
Example:
Bool = True | False -- 2 values
Direction = Up | Down | Left | Right -- 4 values
Point = (Bool, Direction) -- 2 * 4 = 8 possible values
Shape = Circle | Square -- 2 + ... values
from dataclasses import dataclass
from typing import Union
import math
# Sum type via dataclasses + Union
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
@dataclass(frozen=True)
class Triangle:
base: float
height: float
Shape = Union[Circle, Rectangle, Triangle]
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return math.pi * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return 0.5 * b * h
def perimeter(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 2 * math.pi * r
case Rectangle(width=w, height=h):
return 2 * (w + h)
case Triangle(base=b, height=h):
# Approximate for isosceles
side = math.sqrt((b / 2) ** 2 + h ** 2)
return b + 2 * side
# More complex ADT: Expression tree
@dataclass(frozen=True)
class Num:
value: float
@dataclass(frozen=True)
class Add:
left: 'Expr'
right: 'Expr'
@dataclass(frozen=True)
class Mul:
left: 'Expr'
right: 'Expr'
@dataclass(frozen=True)
class Neg:
operand: 'Expr'
Expr = Union[Num, Add, Mul, Neg]
def evaluate(expr: Expr) -> float:
match expr:
case Num(value=v):
return v
case Add(left=l, right=r):
return evaluate(l) + evaluate(r)
case Mul(left=l, right=r):
return evaluate(l) * evaluate(r)
case Neg(operand=o):
return -evaluate(o)
# (3 + 4) * -(2)
expr = Mul(Add(Num(3), Num(4)), Neg(Num(2)))
print(evaluate(expr)) # -14.0

Exhaustive Matching

Exhaustive matching means the compiler ensures every possible variant is handled. If you add a new variant to a sum type, the compiler flags every match/switch that does not handle it.

Exhaustive matching:
sealed interface Shape permits Circle, Rectangle, Triangle {}
// This switch is exhaustive -- no default needed
double area(Shape s) {
return switch (s) {
case Circle c -> ...
case Rectangle r -> ...
case Triangle t -> ...
};
}
// If you add a new variant:
record Hexagon(...) implements Shape {}
// Compiler error: switch is no longer exhaustive!
// "the switch expression does not cover all possible input values"
// You are forced to handle the new case. No bug can sneak through.

Languages with Exhaustive Matching

LanguageMechanismEnforced At
Rustmatch on enumsCompile time (hard error)
Java 21switch on sealed interfacesCompile time (hard error)
Scalamatch on sealed traitsCompile time (warning)
Haskellcase on data typesCompile time (warning with -Wall)
Kotlinwhen on sealed classesCompile time (hard error)
TypeScriptDiscriminated unions + neverCompile time (type error)
Pythonmatch/caseNot enforced (linter support via Pyright)

Pattern Matching Best Practices

Pattern ordering:
WRONG (catch-all first):
match value:
case _: <-- matches everything, others unreachable
...
case Circle(r): <-- never reached!
...
RIGHT (specific first):
match value:
case Circle(r): <-- specific pattern first
...
case Rectangle(w, h):
...
case _: <-- catch-all last
...

Real-World Example: JSON Parsing

from dataclasses import dataclass
from typing import Union
# ADT for JSON values
@dataclass(frozen=True)
class JNull:
pass
@dataclass(frozen=True)
class JBool:
value: bool
@dataclass(frozen=True)
class JNumber:
value: float
@dataclass(frozen=True)
class JString:
value: str
@dataclass(frozen=True)
class JArray:
items: tuple # tuple for immutability
@dataclass(frozen=True)
class JObject:
fields: tuple # tuple of (key, value) pairs
JsonValue = Union[JNull, JBool, JNumber, JString, JArray, JObject]
def stringify(json_val: JsonValue, indent: int = 0) -> str:
pad = " " * indent
match json_val:
case JNull():
return "null"
case JBool(value=v):
return "true" if v else "false"
case JNumber(value=v):
return str(v)
case JString(value=v):
return f'"{v}"'
case JArray(items=items):
if not items:
return "[]"
inner = ",\n".join(
f"{pad} {stringify(item, indent + 1)}"
for item in items
)
return f"[\n{inner}\n{pad}]"
case JObject(fields=fields):
if not fields:
return "{}" # use backticks in prose, braces OK in code
inner = ",\n".join(
f'{pad} "{k}": {stringify(v, indent + 1)}'
for k, v in fields
)
return f"{{\n{inner}\n{pad}}}"
# Build a JSON structure
doc = JObject((
("name", JString("Alice")),
("age", JNumber(30)),
("active", JBool(True)),
("scores", JArray((JNumber(95), JNumber(87), JNumber(92)))),
("address", JNull()),
))
print(stringify(doc))

Summary

ConceptKey Takeaway
Pattern matchingMatch on structure, type, and value — not just equality
DestructuringExtract fields from data structures as part of the match
Sum typesA type that is one of several variants (OR)
Product typesA type that combines multiple fields (AND)
Algebraic data typesSum types + product types — the foundation of FP data modeling
Exhaustive matchingCompiler ensures all variants are handled; no forgotten cases
Guard clausesAdditional conditions on a pattern: case x if x > 0
WildcardThe _ pattern matches anything — use as the last case