Skip to content

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 cast or isinstance.
  • 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 qualified ErrorCode.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

  1. match replaces what? → if isinstance chains
  2. Exhaustiveness via…? → case other: assert_never(other)
  3. Guards for…? → Conditional branches
  4. Type narrowing? → Branch knows exact variant
  5. Always end match with…? → case other: assert_never(other)

8. Post-Core Exercise

  1. Refactor one if isinstance chain to match → add case other: assert_never(other).
  2. Add a new variant to an existing sum type → verify mypy errors in all match sites.
  3. Use guards to handle retryable errors in a Result match.
  4. 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.