M05C07: Pattern Matching in Python 3.10+ for ADTs¶
Progression Note¶
By the end of Module 5, you will model every domain concept as immutable algebraic data types (products and tagged sums), eliminating whole classes of runtime errors through exhaustive pattern matching, mypy-checked totality, and pure serialization contracts.
| Module | Focus | Key Outcomes |
|---|---|---|
| 4 | Safe Recursion & Error Handling | Stack-safe tree recursion, folds, Result/Option, streaming validation/retries |
| 5 | Advanced Type-Driven Design | ADTs, exhaustive pattern matching, total functions, refined types |
| 6 | Monadic Flows as Composable Pipelines | bind/and_then, Reader/State-like patterns, error-typed flows |
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?
We now take the frozen ADTs from C01–C06 and ask the question every growing codebase eventually faces:
“Why do I have 47 copies of the same 15-line if isinstance(x, Ok): … elif isinstance(x, Err): … boilerplate, and why does adding a new variant silently break half my handlers without mypy noticing?”
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
Duplicated boilerplate, easy to miss cases, no compiler help when adding variants.
The production pattern: use match with class patterns + guards + a final case other: assert_never(other) branch → concise, exhaustive, type-narrowing, refactor-safe.
# 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
Adding a new variant forces every match site to update — forever.
Audience: Engineers tired of isinstance spaghetti who want mathematically exhaustive, type-narrowing case analysis.
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 (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():).
Next: M05C08 – ADT Serialization Contracts (to_dict/from_dict, Versioning, Stability).
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.