M06C01: and_then / bind Patterns for Result/Option Pipelines¶
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 replace nested if err/None checks and manual error propagation with monadic and_then (a.k.a. bind or flat_map) — chaining dependent fallible/optional steps while automatically short-circuiting on the first failure or absence?
We take the pure ADTs from Module 5 and ask the question every pipeline eventually faces:
“Why do I have 47 copies of the same 8-line if res.is_err(): return res boilerplate, and why does adding a new step silently break half the pipeline?”
The naïve pattern everyone writes first:
# BEFORE – manual propagation everywhere
def embed_chunk(c: Chunk) -> Result[EmbeddedChunk, ErrInfo]:
tokenized = tokenize(c.text.content)
if tokenized.is_err():
return tokenized
encoded = model.encode(tokenized.value)
if encoded.is_err():
return encoded
return Ok(replace(c, embedding=Embedding(encoded.value, model.name)))
Duplicated checks, easy to forget a propagation, no compiler help.
The production pattern: every fallible or optional step returns a container, and and_then chains them — automatically short-circuiting on the first failure/absence.
Repo note: examples in this module use the Module-05 "compositional domain model" types:
ChunkText.content/Embeddingviafrom funcpipe_rag.rag.domain import Chunk, Embedding. The main RAG pipeline value types live separately infuncpipe_rag.core.rag_types(those chunks usetext: str).
# AFTER – one lawful, composable pipeline (instance methods = canonical style)
def embed_chunk(c: Chunk) -> Result[EmbeddedChunk, ErrInfo]:
return (
Ok(c.text.content)
.and_then(tokenize)
.and_then(model.encode)
.and_then(lambda vec: Ok(replace(c, embedding=Embedding(vec, model.name))))
)
Note: the original c and model are captured via closure here (perfectly valid). We will replace this exact pattern with a Reader monad in M06C04 so dependencies are explicit and testable.
Adding a new step or changing error handling is a single-line change — forever.
Audience: Engineers tired of manual error propagation who want mathematically lawful, automatically short-circuiting pipelines.
Outcome
1. Every nested if err/None replaced with and_then.
2. All pipelines proven to satisfy monad laws (left/right identity, associativity).
3. Linear, readable, refactor-safe flows.
Tiny Non-Domain Example – Safe Division Chain¶
def safe_div(a: float, b: float) -> Result[float, str]:
return Err("div by zero") if b == 0 else Ok(a / b)
chain = (
Ok(12.0)
.and_then(lambda x: safe_div(x, 4.0))
.and_then(lambda x: safe_div(x, 3.0))
.and_then(lambda x: safe_div(x, 0.0)) # short-circuits here
)
# → Err("div by zero")
The failing step short-circuits automatically — no manual checks.
Why and_then? (Three bullets every engineer should internalise)¶
- Automatic short-circuit: First
Err/NoneValimmediately propagates — no forgotten error paths. - Lawful composition:
m.and_then(f).and_then(g) == m.and_then(lambda x: f(x).and_then(g))— refactoring is safe. - Linear happy path: Success case reads top-to-bottom with no nesting — the code finally matches the mental model.
We explicitly do not use Optional[T] as Option[T] — absence is a first-class ADT (Some[T] | NoneVal) with its own lawful and_then.
1. Laws & Invariants (machine-checked)¶
| Law | Formal Statement | Enforcement |
|---|---|---|
| Left Identity | Ok(x).and_then(f) == f(x) |
Hypothesis |
| Right Identity | m.and_then(Ok) == m |
Hypothesis |
| Associativity | m.and_then(f).and_then(g) == m.and_then(lambda x: f(x).and_then(g)) |
Hypothesis |
| Short-circuit | Err(e).and_then(f) == Err(e) / NoneVal().and_then(f) == NoneVal() |
Hypothesis + runtime |
2. Decision Table – When to Use Which Container¶
| Scenario | May fail? | May be absent? | Accumulate errors? | Use |
|---|---|---|---|---|
| API call / parsing | Yes | No | No | Result + and_then |
| Lookup / config key | No | Yes | No | Option + and_then |
| Multi-field validation | Yes | No | Yes | Validation + v_ap (see M06C03) |
3. Public API – Instance methods are the canonical, idiomatic API¶
Type-system note: Python’s type system cannot express that the error branch of Result[T, E] is independent of the success type parameter T.
In the actual codebase (src/funcpipe_rag/result/types.py) we keep mypy --strict green without type: ignore by re-wrapping the error value (e.g. returning Err(self.error)) in methods like map / and_then when the success type changes.
# src/funcpipe_rag/result/types.py – end-of-Module-06 (excerpted)
from __future__ import annotations
from dataclasses import dataclass
from typing import Generic, Callable, TypeVar, Any
T = TypeVar("T")
U = TypeVar("U")
E = TypeVar("E")
# ==================== Result ====================
@dataclass(frozen=True)
class Ok(Generic[T, E]):
value: T
def and_then(self, f: Callable[[T], "Result[U, E]"]) -> "Result[U, E]":
return f(self.value)
def map(self, f: Callable[[T], U]) -> "Result[U, E]":
return Ok(f(self.value))
def or_else(self, _: Callable[[E], T]) -> T:
return self.value
def tap(self, side: Callable[[T], None]) -> "Ok[T, E]":
side(self.value)
return self
@dataclass(frozen=True)
class Err(Generic[T, E]):
error: E
def and_then(self, _: Callable[[T], "Result[U, E]"]) -> "Result[U, E]":
return Err(self.error)
def map(self, _: Callable[[T], U]) -> "Err[U, E]":
return Err(self.error)
def or_else(self, op: Callable[[E], T]) -> T:
return op(self.error)
def tap(self, _: Callable[[T], None]) -> "Err[T, E]":
return self
Result = Ok[T, E] | Err[T, E]
# ==================== Option ====================
@dataclass(frozen=True)
class Some(Generic[T]):
value: T
def and_then(self, f: Callable[[T], "Option[U]"]) -> "Option[U]":
return f(self.value)
def map(self, f: Callable[[T], U]) -> "Option[U]":
return Some(f(self.value))
def or_else(self, op: Callable[[], T]) -> T:
return self.value
def tap(self, side: Callable[[T], None]) -> "Some[T]":
side(self.value)
return self
@dataclass(frozen=True)
class NoneVal:
def and_then(self, _: Callable[[Any], "Option[U]"]) -> "Option[U]":
return self
def map(self, _: Callable[[Any], U]) -> "Option[U]":
return self
def or_else(self, op: Callable[[], T]) -> T:
return op()
def tap(self, _: Callable[[Any], None]) -> "NoneVal":
return self
Option = Some[T] | NoneVal
Free-function wrappers exist only as one-liners for stylistic preference or Kleisli composition:
def result_and_then(r: Result[T, E], f: Callable[[T], Result[U, E]]) -> Result[U, E]:
return r.and_then(f)
Policy: In this series we use the instance method syntax everywhere (value.and_then(f)). It is the most readable and idiomatic. Free functions are purely optional.
4. Reference Implementations¶
4.1 RAG Integration – Embedding Pipeline (real-world closure usage)¶
def embed_chunk(c: Chunk) -> Result[EmbeddedChunk, ErrInfo]:
return (
Ok(c.text.content)
.and_then(tokenize)
.and_then(model.encode)
.and_then(lambda vec: Ok(replace(c, embedding=Embedding(vec, model.name))))
)
4.2 Config Parsing – Pure Result chain (absence = error here)¶
parse_config = lambda raw: (
safe_json_load(raw) # -> Result[dict, ErrInfo]
.and_then(ensure(is_dict, "NOT_DICT"))
.and_then(require_key("port", "MISSING_PORT"))
.and_then(get("port"))
.and_then(ensure(is_int, "BAD_PORT"))
.and_then(ensure(in_range(1, 65535), "PORT_OOR"))
.and_then(build_config)
)
All steps return Result[_, ErrInfo]. Missing key = error, not mere absence → stays cleanly in one monad.
5. Property-Based Proofs (tests/test_monad_laws.py)¶
from hypothesis import given, strategies as st
from funcpipe_rag.result.types import Ok, Err, Some, NoneVal, Result, Option
def st_result(t: st.SearchStrategy, e: st.SearchStrategy):
return st.one_of(st.builds(Ok, t), st.builds(Err, e))
def st_option(t: st.SearchStrategy):
return st.one_of(st.builds(Some, t), st.just(NoneVal()))
@given(x=st.integers())
def test_result_left_identity(x):
f = lambda v: Ok(v * 2)
assert Ok(x).and_then(f) == f(x)
@given(m=st_result(st.integers(), st.text()))
def test_result_right_identity(m):
assert m.and_then(Ok) == m
@given(m=st_result(st.integers(), st.text()))
def test_result_associativity(m):
f = lambda a: Ok(a + 1)
g = lambda b: Ok(b * 2)
assert m.and_then(f).and_then(g) == m.and_then(lambda a: f(a).and_then(g))
@given(e=st.text())
def test_result_short_circuit(e):
assert Err(e).and_then(lambda _: Ok(999)) == Err(e)
@given(m=st_result(st.integers(), st.text()))
def test_result_tap_transparent(m):
seen = []
out = m.tap(lambda v: seen.append(v))
assert out == m
assert seen == ([m.value] if isinstance(m, Ok) else [])
@given(x=st.integers())
def test_option_left_identity(x):
f = lambda v: Some(v * 2)
assert Some(x).and_then(f) == f(x)
@given(o=st_option(st.integers()))
def test_option_right_identity(o):
assert o.and_then(Some) == o
@given(o=st_option(st.integers()))
def test_option_associativity(o):
f = lambda a: Some(a + 1) if a % 2 == 0 else NoneVal()
g = lambda b: Some(b * 2)
assert o.and_then(f).and_then(g) == o.and_then(lambda a: f(a).and_then(g))
@given(o=st_option(st.integers()))
def test_option_tap_transparent(o):
seen = []
out = o.tap(lambda v: seen.append(v))
assert out == o
assert seen == ([o.value] if isinstance(o, Some) else [])
6. Big-O & Allocation Guarantees¶
| Operation | Time | Heap | Notes |
|---|---|---|---|
| and_then / map / tap | O(1) | O(1) | Combinators themselves; total cost dominated by user function f (may allocate new container) |
7. Anti-Patterns & Immediate Fixes¶
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| Nested if/try/except | Verbose, easy to miss propagation | Chain with and_then |
| Manual flattening | Nested Result/Option | and_then auto-flattens |
| Side effects in the function to and_then | Impure pipelines, non-reproducible | Keep pure; use tap only for observation/logging |
| Ignoring laws | Refactor silently breaks | Hypothesis law tests in CI |
8. Pre-Core Quiz¶
-
and_thenis for…?
→ Chaining dependent fallible/optional steps -
Short-circuits on…?
→ First Err/NoneVal -
Left identity law?
→ Ok(x).and_then(f) == f(x) -
Associativity enables…?
→ Refactor-safe composition (parentheses don’t matter) -
Never use … as a monad?
→ Optional[T] (builtin None)
9. Post-Core Exercise¶
- Take one real
try/except+ manual propagation chain in your codebase and rewrite it withand_then. - Add a new fallible step to that chain and confirm only one line changes.
- Implement
and_then(and the four laws) for a custom two-variant ADT of your own. - Find and eliminate one instance of nested
Result[Result[...]]orOption[Option[...]]usingand_then.
Next: M06C02 – Law-Guided Design – Identity & Associativity Checks with Hypothesis.
You now chain any number of fallible or optional steps with a single, lawful and_then — short-circuiting automatically, no boilerplate, refactor-safe forever. The rest of Module 6 builds Reader/State/Writer patterns and configurable pipelines on top of these monads.