Refactoring try/except¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Functional Programming"]
section["Monadic Flow Explicit Context"]
page["Refactoring try/except"]
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"]
Read the first diagram as a placement map: this page is one concept inside its parent module, not a detached essay, and the capstone is the pressure test for whether the idea holds. Read the second diagram as the working rhythm for the page: name the problem, study the example, identify the boundary, then carry one review question forward.
Progression Note¶
Module 6 shifts from pure data modelling to effect-aware composition.
We now treat failure and absence as first-class effects that propagate automatically through pipelines — eliminating nested conditionals forever.
| Module | Focus | Key Outcomes |
|---|---|---|
| 5 | Algebraic Data Modelling | ADTs, exhaustive pattern matching, total functions, refined types |
| 6 | Monadic Flows as Composable Pipelines | bind/and_then, Reader/State-like patterns, error-typed flows |
| 7 | Effect Boundaries & Resource Safety | Dependency injection, boundaries, testing, evolution |
Core question
How do you systematically refactor the inevitable try/except + if None spaghetti that accumulates in every real codebase into clean, linear, composable monadic pipelines — without changing public return behaviour and while gaining testability and refactor safety?
This is the core where you finally pay off the promise of the entire module: you take real, ugly, production-grade imperative error handling and turn it into pure, lawful, beautiful FP — with mechanical proof that the public contract is identical.
Use this when you know the theory but still have a codebase full of nested try/except and want a repeatable, safe refactoring process.
Outcome 1. You will have a mechanical 6-step process for turning any try/except mess into a monadic pipeline. 2. You will prove (with Hypothesis) that the refactored version has identical public return behaviour for all inputs. 3. You will never again fear “what if I break something?” when cleaning up error handling.
The 6-Step Refactoring Process (memorise this)¶
- Identify every error path (exceptions, None checks, invalid states).
- Define typed domain errors (
ParseErr,ValidationErr,NetworkErr, etc.). - Extract pure functions for each step.
- Bridge impurities at the boundary with
try_result/option_from_nullable. - Chain with
.and_then/.map/ applicative combinators (.ap,v_liftA2). - Add Hypothesis equivalence tests and delete the old code.
Do this once per function and you’ll never go back.
Keep The Public Contract Stable First¶
The safest first pass is usually internal refactor, external contract unchanged:
- rewrite the internals into
Result,Option, orValidation - keep a thin boundary adapter that preserves the old return type if callers still depend on it
- prove equivalence before deciding whether the public API itself should change
That ordering keeps the refactor reviewable. It separates “the flow is now cleaner” from “the public contract changed,” which are two different decisions.
1. Laws & Invariants (machine-checked in CI)¶
| Invariant | Description | Enforcement |
|---|---|---|
| Behavioural Equivalence | Refactored pipeline produces identical public return values for all inputs | Hypothesis properties |
| Totality (expected errors) | Expected errors always return container (never raise) | Hypothesis + runtime |
| Unexpected failures still raise | Programming bugs remain exceptions (never silently become domain Err) | Runtime contract |
| No New Side Effects | Refactored boundary preserves or reduces side effects (never introduces new kinds of effects) | Code review |
| Propagation | Errors short-circuit exactly where original would have returned/raised | Hypothesis |
All equivalence properties run in CI. A single divergence fails the build.
2. Decision Table – What to Refactor Into What¶
| Original Pattern | Refactor To | Why |
|---|---|---|
| try/except ValueError | try_result(..., map_exc, exc_type=ValueError) + .and_then |
Typed domain error |
| if value is None | option_from_nullable(value).and_then(...) |
Explicit absence |
| Nested try/except | Chained .and_then |
Linear happy path |
| Multiple independent checks | Validation + v_ap / v_liftA2 |
Accumulate all errors |
3. Public API – No new helpers (use everything from previous cores)¶
You already have the main building blocks you need:
.map,.and_then,.aponResult.map,.and_thenonOptionv_ap,v_liftA2(Validation applicative helpers; Validation is a sum type, not a methodful class)try_result(withexc_typebelow),result_map_trySome/NoneVal/option_from_nullable
Updated try_result (matches the repository implementation)¶
def try_result(
thunk: Callable[[], T],
map_exc: Callable[[Exception], E],
exc_type: type[Exception] | tuple[type[Exception], ...] = Exception,
) -> Result[T, E]:
"""Bridge an impure thunk into Result. Use ONLY at effect boundaries.
exc_type restricts which exceptions are treated as expected domain errors.
All others propagate as bugs (never become Err).
"""
try:
return Ok(thunk())
except exc_type as ex:
return Err(map_exc(ex))
4. The Full Refactoring Cookbook – Three Real Examples¶
4.1 JSON Parsing + Processing (classic nested try/except)¶
# BEFORE – the spaghetti everyone has
def load_and_process(path: str) -> dict | None:
try:
raw = open(path).read()
except IOError as e:
log.error(f"IO error: {e}")
return None
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
log.error(f"JSON error: {e}")
return None
try:
return process(data)
except ValueError as e:
log.error(f"Processing error: {e}")
return None
# AFTER – pure, linear, testable
@dataclass(frozen=True)
class IOErrorErr: msg: str
@dataclass(frozen=True)
class JSONErr: msg: str
@dataclass(frozen=True)
class ProcessErr: msg: str
Err = IOErrorErr | JSONErr | ProcessErr
def load_and_process(path: str) -> Result[dict, Err]:
return (
try_result(lambda: open(path).read(), lambda e: IOErrorErr(str(e)), exc_type=OSError)
.and_then(
lambda raw: try_result(
lambda: json.loads(raw),
lambda e: JSONErr(str(e)),
exc_type=json.JSONDecodeError,
)
)
.and_then(
lambda data: try_result(
lambda: process(data),
lambda e: ProcessErr(str(e)),
exc_type=ValueError,
)
)
)
# Boundary (if you need to match original None return)
def load_and_process_legacy(path: str) -> dict | None:
match load_and_process(path):
case Ok(data): return data
case Err(e): log.error(e.msg); return None
Zero nesting. Happy path is three lines. Every error is typed and testable. The thin legacy adapter is part of the refactoring story, not a hack. It is what lets you improve the internals without forcing a contract change on every caller at once.
4.2 Multi-field Validation (independent errors)¶
# BEFORE – early returns, only first error visible
def validate_user(name: str | None, age: int | None, email: str | None) -> dict | str:
errors = []
if not name:
errors.append("name missing")
if not age or age < 0:
errors.append("invalid age")
if not email or "@" not in email:
errors.append("invalid email")
if errors:
return "; ".join(errors)
return {"name": name, "age": age, "email": email}
# AFTER – Validation accumulates all errors
def validate_user(name: str | None, age: int | None, email: str | None) -> Validation[User, str]:
v_name = validate_name(name)
v_age = validate_age(age)
v_email = validate_email(email)
partial_user: Validation[Callable[[str], User], str] = v_liftA2(
lambda n, a: lambda e: User(n, a, e),
v_name,
v_age,
)
from funcpipe_rag.fp.validation import v_ap
return v_ap(partial_user, v_email)
# Boundary – preserve original dict | str contract
def validate_user_legacy(name: str | None, age: int | None, email: str | None) -> dict | str:
match validate_user(name, age, email):
case VSuccess(user): return {"name": user.name, "age": user.age, "email": user.email}
case VFailure(errors): return "; ".join(errors)
All errors always reported. Zero manual error collection.
4.3 Optional Chaining (absence checks)¶
# BEFORE – pyramid of doom
def get_user_email(id: int) -> str | None:
user = db.get_user(id)
if user is None:
return None
profile = user.get("profile")
if profile is None:
return None
return profile.get("email")
# AFTER – Option chain
def get_user_email(id: int) -> Option[str]:
return (
option_from_nullable(db.get_user(id))
.and_then(lambda user: option_from_nullable(user.get("profile")))
.and_then(lambda profile: option_from_nullable(profile.get("email")))
)
# Boundary – preserve original str | None contract
def get_user_email_legacy(id: int) -> str | None:
return get_user_email(id).unwrap_or(None)
Zero pyramid. Absence propagates automatically.
5. Proving Equivalence – The Safety Net¶
@given(st.text())
def test_json_refactor_equivalence(raw: str):
@dataclass(frozen=True)
class ParseErr:
msg: str
# Imperative version
def imp() -> dict | None:
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
# FP version
def fp() -> Result[dict, ParseErr]:
return try_result(
lambda: json.loads(raw),
lambda e: ParseErr(str(e)),
exc_type=json.JSONDecodeError,
)
# Boundary to match original
def fp_boundary() -> dict | None:
return fp().unwrap_or(None)
assert imp() == fp_boundary()
Run this in CI. If it ever fails, you broke the refactor.
Review The Rewrite In Three Passes¶
When reviewing one of these refactors, read it in this order:
- contract pass: does the boundary still return the old public shape?
- classification pass: are expected failures, absence, and bugs now separated more clearly?
- composition pass: do the extracted helpers and combinators actually make the flow easier to inspect?
That order keeps the review grounded in behavior first and style second.
6. Anti-Patterns & Immediate Fixes¶
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| Nested try/except | Unreadable indentation | Chain with .and_then |
| Early returns with error strings | Only first error visible | Use Validation + .ap / v_liftA2 |
| if value is None pyramid | Deep nesting | Chain with .and_then on Option |
| No equivalence tests | "It works on my machine" | Add Hypothesis equivalence properties |
7. Pre-Core Quiz¶
- First step in refactoring? → Identify every error path
- Bridge exceptions with? → try_result at boundary
- Chain dependent steps with? → .and_then
- Accumulate independent errors with? → Validation + .ap / v_liftA2
- Prove safety with? → Hypothesis equivalence tests
8. Post-Core Exercise¶
- Take the ugliest try/except function in your codebase and apply the 6-step process.
- Add Hypothesis equivalence tests and delete the old imperative version.
- Celebrate — you now have one less piece of spaghetti forever.
Continue with: Configurable Pipelines
You have now refactored imperative error handling into pure, composable, mathematically proven pipelines. Your codebase is dramatically cleaner, safer, and ready for the final architectural patterns.