Pattern Matching¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Functional Programming"]
section["Algebraic Data Modelling Validation"]
page["Pattern Matching"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
Pattern matching should feel like a safer way to read domain variants, not like syntactic novelty. match is valuable because it lines up naturally with tagged sums and makes missing cases harder to hide.
Start With the Branching Boilerplate¶
Once a codebase has explicit variants, the next problem is how to handle them cleanly. Long if isinstance chains often preserve the information, but they bury the model under branching boilerplate.
- If every handler repeats the same variant tests and attribute extraction, the branch structure is harder to review than it needs to be.
- If adding a new variant can slip past existing handlers, the dispatch style is still too fragile.
- If you cannot tell what each branch assumes about the payload, the case analysis is not yet readable enough.
Core question
How do you replace verbose, fragile if isinstance chains with Python 3.10+ match statements that destructuringly pattern-match on ADTs — guaranteeing exhaustive handling, automatic type narrowing, and refactor-safety in every FuncPipe pipeline stage?
This lesson introduces match as the natural case-analysis syntax for ADTs:
- destructure variants directly where they are handled
- keep guards and payload extraction close to the matching branch
- use the final
assert_neverpattern to keep the closed-union promise honest
The motivating Result example matters because it is exactly the kind of branch-heavy code that appears everywhere once variants are available.
The naïve pattern everyone writes first:
# BEFORE – verbose, fragile, non-exhaustive
def handle_result(res: Result[T, ErrInfo]) -> T:
if isinstance(res, Ok):
return res.value
elif isinstance(res, Err):
if res.error.code == ErrorCode.RETRYABLE:
return retry(res.error)
else:
raise RuntimeError(res.error.msg)
# oops, someone added Pending and forgot to handle it → silent crash or wrong path
This is the branching boilerplate to replace.
The production pattern makes the variant structure visible in the code shape itself and gives tooling a better chance to help.
# AFTER – one lawful, exhaustive block
def handle_result(res: Result[T, ErrInfo]) -> T:
match res:
case Ok(value=v):
return v
case Err(error=e) if e.code is ErrorCode.RETRYABLE:
return retry(e)
case Err(error=e):
raise RuntimeError(e.msg)
case other:
assert_never(other) # mypy errors if you add Pending and forget to handle
That is the real promise to care about: changes to the model become visible pressure on the handling sites instead of silent drift.
Use this when you are tired of isinstance spaghetti and want clearer, more trustworthy case analysis over ADTs.
Outcome
1. Every if isinstance chain replaced with match.
2. All matches statically checked for exhaustiveness via case other: assert_never(other) on closed unions.
3. Readable, safe, refactor-proof ADT handling.
Tiny Non-Domain Example – Shape Area¶
match shape:
case Circle(radius=r):
return 3.14159 * r * r
case Rectangle(width=w, height=h):
return w * h
case other:
assert_never(other) # mypy errors if you add Triangle and forget
Adding Triangle breaks every site until handled — no silent wrong-path.
Why Pattern Matching for ADTs? (Three bullets every engineer should internalise)¶
- Exhaustiveness:
case other: assert_never(other)+ closed union types → adding a variant forces every match to update. - Type narrowing: Branches know exact variant → no more
castorisinstance. - Readability + safety: Declarative patterns + guards replace nested if-elif chains forever.
Use match only on core frozen dataclasses. Pydantic models are converted at the edge (C06).
Setup – Imports & Core ADT Recap¶
from typing_extensions import assert_never # critical for exhaustiveness
# In this repo, use the production ADTs:
from funcpipe_rag.fp.core import Some, NoneVal
from funcpipe_rag.fp.error import ErrorCode
from funcpipe_rag.result.types import Ok, Err, Result, ErrInfo
1. Laws & Invariants (machine-checked)¶
| Invariant | Description | Enforcement |
|---|---|---|
| Exhaustiveness | All variants handled (case other: assert_never) |
mypy (with closed unions) + runtime |
| Type Narrowing | Each case narrows to exact variant | mypy strict mode |
| Guard Purity | Guards are pure expressions | Code review + tests |
| No Side Effects | Patterns/guards do no I/O or mutation | Reproducibility tests |
With Result = Ok[T] | Err[E] defined as a closed union and a case other: assert_never(other) branch, mypy will error when you add a new variant and forget to handle it.
2. Decision Table – match vs if isinstance¶
| Scenario | Need guards? | Need destructuring? | Use match? |
|---|---|---|---|
| Simple binary (Ok/Err) | No | Yes | Yes |
| With retry logic | Yes | Yes | Yes |
| Deeply nested product | No | Yes | Yes |
| Performance-critical path | No | No | Optional |
| Python <3.10 | – | – | No |
Gotchas (every engineer must internalise)¶
- Capture vs constant: Bare name captures → use
Literal["kind"]or qualifiedErrorCode.RETRYABLE. - Positional matching: Dataclasses expose fields via
__match_args__; reordering fields silently breaks positional patterns → always use keyword patterns (case Ok(value=v)). - Guards: Must be pure and fast — do work after the match.
- Exhaustiveness: Always end with
case other: assert_never(other).
3. Reference Implementations¶
3.1 Matching Option¶
def unwrap_or(opt: Option[T], default: T) -> T:
match opt:
case Some(value=v):
return v
case NoneVal():
return default
case other:
assert_never(other)
3.2 Matching Result with Guards¶
def handle_result(res: Result[T, ErrInfo]) -> T:
match res:
case Ok(value=v):
return v
case Err(error=e) if e.code is ErrorCode.RETRYABLE:
return retry(e)
case Err(error=e):
raise RuntimeError(e.msg)
case other:
assert_never(other)
3.3 Matching Validation (or-patterns)¶
def handle_validation(val: Validation[T, ErrInfo]) -> T:
match val:
case VSuccess(value=v):
return v
case VFailure(errors=(e,)) if e.code is ErrorCode.RETRYABLE:
return retry_single(e)
case VFailure(errors=es):
raise MultipleErrors(es)
case other:
assert_never(other)
3.4 RAG Integration – Embedding Result Handling¶
def embed_with_fallback(res: Result[Embedding, ErrInfo]) -> Embedding:
match res:
case Ok(value=emb):
return emb
case Err(error=e) if e.code in {ErrorCode.TRANSIENT, ErrorCode.RATE_LIMIT}:
return fallback_embedding(e)
case Err(error=e):
log_and_raise(e)
case other:
assert_never(other)
4. Property-Based Proofs (capstone/tests/test_pattern_matching.py)¶
from hypothesis import given, strategies as st
@given(v=st.integers())
def test_option_unwrap_or_some(v):
assert unwrap_or(Some(value=v), default=-1) == v
@given(default=st.integers())
def test_option_unwrap_or_none(default):
assert unwrap_or(NoneVal(), default) == default
@given(res=st.one_of(st.builds(Ok, value=st.integers()),
st.builds(Err, error=st.builds(ErrInfo, code=st.sampled_from(ErrorCode), msg=st.text()))))
def test_result_match_exhaustive(res):
# This test verifies no runtime crash; exhaustiveness is enforced by mypy
# via assert_never on closed unions.
def dummy(r: Result[int, ErrInfo]) -> int:
match r:
case Ok(value=v):
return v
case Err(error=_):
return -1
case other:
assert_never(other)
# unreachable
dummy(res)
5. Big-O & Allocation Guarantees¶
| Operation | Time | Heap | Notes |
|---|---|---|---|
| match on ADT | O(1) | O(1) | Constant time dispatch |
6. Anti-Patterns & Immediate Fixes¶
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| Long if-isinstance chains | Verbose, easy to miss cases | Replace with match + case other: assert_never |
| Missing case other | Silent bugs on new variants | Always end match with case other: assert_never(other) |
| Side effects in guards | Non-deterministic behaviour | Keep guards pure |
| Positional matching | Brittle on field reorder | Prefer keyword patterns (value=v) |
| Bare name capture | Unexpected rebinding | Use literals or qualified names |
7. Pre-Core Quiz¶
matchreplaces what? →if isinstancechains- Exhaustiveness via…? →
case other: assert_never(other) - Guards for…? → Conditional branches
- Type narrowing? → Branch knows exact variant
- Always end match with…? →
case other: assert_never(other)
8. Post-Core Exercise¶
- Refactor one
if isinstancechain tomatch→ addcase other: assert_never(other). - Add a new variant to an existing sum type → verify mypy errors in all match sites.
- Use guards to handle retryable errors in a Result match.
- Replace a nested if-elif with or-patterns (
case Ok() | Some():).
Continue with: Serialization Beyond Pydantic
You now destructure and handle every ADT with concise, exhaustive, type-narrowing match statements — no more isinstance boilerplate, no more silent missing cases. The rest of Module 5 adds stable serialization, compositional domain models, and performance guidance for heavy ADTs.