and_then and bind¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Functional Programming"]
section["Monadic Flow Explicit Context"]
page["and_then and bind"]
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"]
and_then should feel like a readability tool first. You usually do not need another name for “monad” on day one. You need a way to stop rewriting the same propagation checks and let dependent steps read like the happy path again.
Start With the Propagation Smell¶
You often already know the sequence of operations you want. What gets in the way is the repetitive structure around them.
- If every step is followed by “if error, return error,” the code is repeating the container contract instead of the business logic.
- If adding one more dependent step means editing several propagation branches, the flow is too brittle.
- If you cannot see where the chain stops on failure, the control rule is still harder to inspect than it needs to be.
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?
The Fast Choice Rule¶
Start with one practical test before adding more vocabulary:
- use
.map(f)whenfreturns a plain value and stays inside the current success path - use
.and_then(f)whenfalready returns the same container and may short-circuit the rest of the flow
That distinction is the whole reason and_then removes propagation boilerplate instead of
just moving it around.
This lesson introduces and_then as the standard way to express dependent steps over fallible or optional values:
- keep the success path linear and readable
- let the container propagate failure or absence automatically
- rely on the lawfulness of the chain so regrouping and extraction stay safe
The repeated-propagation examples matter because they show the exact maintenance tax you are trying to remove.
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)))
This is the propagation smell to recognize immediately.
The production pattern leaves each step responsible only for its own transformation and lets and_then carry the propagation rule.
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))))
)
Read that chain left to right:
- start with the chunk text inside
Ok(...) - call
tokenize, which may returnErr - call
model.encode, which may returnErr - rebuild the chunk only if both earlier steps succeeded
The chain stays linear because each step is only responsible for its own work. The container is responsible for the stop-on-failure rule.
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.
Now adding or reordering a dependent step is a localized change instead of a control-flow rewrite.
Use this when you are tired of manual error propagation and want dependent pipelines that stay linear, inspectable, and safe to refactor.
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.
A Quick map vs and_then Check¶
This lesson becomes much easier once you can classify the next function correctly:
| Current value | Next function returns | Honest choice |
|---|---|---|
Result[T, E] |
U |
.map |
Result[T, E] |
Result[U, E] |
.and_then |
Option[T] |
U |
.map |
Option[T] |
Option[U] |
.and_then |
That table is worth keeping in mind while reading the larger API and law sections below.
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 (capstone/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.
# capstone/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 (capstone/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.
Continue with: Law-Guided Design
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.