One pattern for chaining steps that carry extra baggage:
missing values, errors, many answers, logs, config, and state.
Maybe, Result, List, Writer, Reader, State. All in plain Python you can run.
Three laws that make chaining safe to refactor, and how to test them.
Categories, functors, (T, η, μ), Kleisli arrows and adjunctions.
Every code block on these slides was run with Python 3.11+. Outputs are shown in dashed boxes.
map inside a boxunit + bindFind a user's zip code. Every lookup can fail. The real logic is three lines. The rest is plumbing.
def zip_for(uid):
user = USERS.get(uid)
if user is None:
return None
aid = user.get("address_id")
if aid is None:
return None
addr = ADDRS.get(aid)
if addr is None:
return None
return addr.get("zip")
def zip_for(uid):
return (get(USERS, uid)
.bind(lambda u: get(u, "address_id"))
.bind(lambda a: get(ADDRS, a))
.bind(lambda addr: get(addr, "zip")))
The “stop if missing” rule now lives in one place: bind.
Same shape repeats for errors, logging, config, state, async, and parsing. A monad is that shared shape.
Keep one picture in your head for the rest of the talk. The words box, map, and bind will all hang on it.
bind. It opens the box, follows that box's rule, and feeds the item to the next station.| Step shape | After one step | |
|---|---|---|
map | A → B | F B |
map with a boxing step | A → F B | F (F B) |
bind | A → F B | F B |
nums = [1, 2, 3] both = lambda x: [x, -x] # a step that returns a box print(list(map(both, nums))) # map: boxes inside a box print([y for x in nums for y in both(x)]) # bind: flat
[[1, -1], [2, -2], [3, -3]] [1, -1, 2, -2, 3, -3]
map keeps the extra layer. After three steps you would have boxes in boxes in boxes. bind is map followed by flatten, so the shape stays F B no matter how long the chain.
Hold on to this: monad = a box type whose steps can be chained without nesting. The next slides make it exact.
map Inside a Box FMAPA functor is a box type F with a way to apply a plain function to what is inside.
Signature
fmap : (A → B) → F A → F B
Functor laws
fmap id = id
fmap (g ∘ f) = fmap g ∘ fmap f
Law 1: mapping “do nothing” does nothing. Law 2: two maps equal one map of the composed function.
list(map(str.upper, ["a", "b"])) # list functor
# ['A', 'B']
{k: v * 2 for k, v in d.items()} # dict values
# Optional as a functor, by hand:
def fmap_opt(f, x):
return None if x is None else f(x)
The limit: map cannot handle an f that itself returns a box. You get F(F B). Monads fix that.
unit + bind DEFINITIONA monad is a type constructor M with two operations:
unit : A → M A
bind : M A → (A → M B) → M B
unit (Haskell: return / pure) puts a plain value in the box.bind (Haskell: >>=) opens the box, feeds the value to the next step, and flattens the result.Minimal Python interface: a static unit(x) and a method bind(self, f). map comes for free: m.bind(lambda x: unit(f(x))).
Write m >>= f for m.bind(f). Every lawful monad must satisfy:
| Law | Equation | Plain meaning |
|---|---|---|
| Left identity | unit(a) >>= f = f(a) | Wrapping then binding adds nothing. |
| Right identity | m >>= unit = m | Binding to “just wrap it” changes nothing. |
| Associativity | (m >>= f) >>= g = m >>= (λx. f(x) >>= g) | Grouping of steps does not matter. |
f = lambda x: Just(x + 1)
g = lambda x: Just(x * 10) if x < 100 else Nothing()
for a in [1, 99, 150]:
m = Just(a)
assert Maybe.unit(a).bind(f) == f(a)
assert m.bind(Maybe.unit) == m
assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g))
print("laws ok")
from dataclasses import dataclass
class Maybe:
@staticmethod
def unit(x): return Just(x)
@dataclass(frozen=True)
class Just(Maybe):
value: object
def map(self, f): return Just(f(self.value))
def bind(self, f): return f(self.value)
@dataclass(frozen=True)
class Nothing(Maybe):
def map(self, f): return self
def bind(self, f): return self # stop here
USERS = {1: {"name": "Ada", "address_id": 10},
2: {"name": "Bob"}}
ADDRS = {10: {"city": "London", "zip": "N1 9GU"}}
def get(d, k):
return Just(d[k]) if k in d else Nothing()
def zip_for(uid):
return (get(USERS, uid)
.bind(lambda u: get(u, "address_id"))
.bind(lambda a: get(ADDRS, a))
.bind(lambda addr: get(addr, "zip")))
print(zip_for(1), zip_for(2), zip_for(9))
Just(value='N1 9GU') Nothing() Nothing()
Math view: Maybe A = 1 + A (a disjoint sum). unit is the right injection.
The laws slide tested three inputs. A proof covers every input. For Maybe it is short: a value is either Nothing() or Just(v), so check both cases.
unit(a).bind(f)
= Just(a).bind(f)
= f(a) ✓
Only one case: unit always builds a Just. The last step is the definition of Just.bind.
Case m = Nothing():
Nothing().bind(unit) = Nothing() ✓
Case m = Just(v):
Just(v).bind(unit) = unit(v) = Just(v) ✓
Case Nothing(): both sides skip every step and give Nothing(). ✓
Case Just(v):
LHS = Just(v).bind(f).bind(g) = f(v).bind(g)
RHS = Just(v).bind(λx. f(x).bind(g)) = f(v).bind(g)
Same expression, so equal. ✓
Split on the shapes a value can take, then unfold bind and unit by their definitions until both sides match. The same method proves the laws for Result (Ok / Err). For List you use induction on the length of the list.
Like Maybe, but the failure carries a message. Think of two rails: once on the error rail, you stay there.
@dataclass(frozen=True)
class Ok:
value: object
def bind(self, f): return f(self.value)
@dataclass(frozen=True)
class Err:
error: str
def bind(self, f): return self
def parse_int(s):
try: return Ok(int(s))
except ValueError: return Err(f"not a number: {s!r}")
def positive(n):
return Ok(n) if n > 0 else Err(f"must be > 0, got {n}")
def safe_recip(n):
return Ok(1 / n)
for s in ["4", "-2", "abc"]:
print(Ok(s).bind(parse_int).bind(positive).bind(safe_recip))
Ok(value=0.25) Err(error='must be > 0, got -2') Err(error="not a number: 'abc'")
Type
EitherE A = E + A
unit = inr (inl e) >>= f = inl e
This is Python's try/except made into a value. You can store it, return it, and pass it around.
Each step may return 0, 1, or many results. bind tries every branch and concatenates.
class ListM:
def __init__(self, xs): self.xs = list(xs)
@staticmethod
def unit(x): return ListM([x])
def bind(self, f):
return ListM(y for x in self.xs for y in f(x).xs)
guard = lambda ok: ListM([None]) if ok else ListM([])
def triples(n):
return ListM(range(1, n)).bind(lambda a:
ListM(range(a, n)).bind(lambda b:
ListM(range(b, n)).bind(lambda c:
guard(a*a + b*b == c*c).bind(lambda _:
ListM.unit((a, b, c))))))
print(triples(20).xs)
[(3, 4, 5), (5, 12, 13), (6, 8, 10), (8, 15, 17), (9, 12, 15)]
[(a, b, c) for a in range(1, 20)
for b in range(a, 20)
for c in range(b, 20)
if a*a + b*b == c*c]
A list comprehension is list-monad do-notation. Each for is a bind. The if is guard.
Math: unit(x) = [x], and join = concat. The list monad is the free monoid monad. More on that in the adjunction slide.
@dataclass(frozen=True)
class Writer:
value: object
log: tuple = ()
@staticmethod
def unit(x): return Writer(x) # empty log
def bind(self, f):
w = f(self.value)
return Writer(w.value, self.log + w.log)
def double(x): return Writer(x * 2, (f"doubled {x}",))
def inc(x): return Writer(x + 1, (f"incremented {x}",))
print(Writer.unit(5).bind(double).bind(inc))
Writer(value=11, log=('doubled 5', 'incremented 10'))
Needs a monoid (W, ⋅, e) for the log:
WriterW A = A × W
unit(a) = (a, e)
(a, w) >>= f = let (b, w′) = f(a) in (b, w ⋅ w′)
Monoid laws (e is an identity, ⋅ is associative) give the monad laws directly. Tuples with +, ints with +, sets with | all work.
Use it for audit trails, cost counters, or collecting warnings without a global logger.
from typing import Callable
@dataclass(frozen=True)
class Reader:
run: Callable # env -> value
@staticmethod
def unit(x): return Reader(lambda env: x)
def bind(self, f):
return Reader(lambda env: f(self.run(env)).run(env))
ask = Reader(lambda env: env) # read the config
def greeting(name):
return ask.bind(lambda cfg:
Reader.unit(f"{cfg['greet']}, {name}!"))
def page(name):
return greeting(name).bind(lambda g:
ask.bind(lambda cfg:
Reader.unit(f"<{cfg['tag']}>{g}</{cfg['tag']}>")))
print(page("Ada").run({"greet": "Hello", "tag": "h1"}))
print(page("Ada").run({"greet": "Hola", "tag": "p"}))
<h1>Hello, Ada!</h1> <p>Hola, Ada!</p>
Type: a function from an environment E.
ReaderE A = E → A
unit(a) = λe. a
(m >>= f)(e) = f(m(e))(e)
Build the whole program first. Supply the config once, at the edge, with .run(env). No function in the middle takes cfg as a parameter.
Swap in a test config to unit-test the same program.
@dataclass(frozen=True)
class State:
run: Callable # s -> (value, new_s)
@staticmethod
def unit(x): return State(lambda s: (x, s))
def bind(self, f):
def step(s):
a, s2 = self.run(s)
return f(a).run(s2) # pass new state along
return State(step)
get_s = State(lambda s: (s, s))
def put(n): return State(lambda s: (None, n))
def rand(): # a tiny LCG
return get_s.bind(lambda seed:
put((1103515245 * seed + 12345) % 2**31).bind(lambda _:
State.unit(seed % 100)))
three = rand().bind(lambda a: rand().bind(lambda b:
rand().bind(lambda c: State.unit([a, b, c]))))
print(three.run(42))
print(three.run(42)) # same seed, same result
([42, 27, 64], 1000676753) ([42, 27, 64], 1000676753)
Type
StateS A = S → A × S
unit(a) = λs. (a, s)
(m >>= f)(s) = let (a, s′) = m(s) in f(a)(s′)
The program is a description. Nothing runs until you call .run(seed). That makes it replayable and easy to test.
State is Reader and Writer glued together: read S, then write a new S. It comes from the adjunction (– × S) ⊣ (S → –).
Nested lambdas get ugly. Python generators can pause at each yield, so they can act like Haskell's do block.
def do(unit):
def deco(genfn):
def run(*args):
gen = genfn(*args)
def step(value):
try:
m = gen.send(value) # next monadic value
except StopIteration as done:
return unit(done.value) # 'return x' -> unit(x)
return m.bind(step) # continue after bind
return step(None)
return run
return deco
@do(Maybe.unit)
def zip_for(uid):
user = yield get(USERS, uid)
aid = yield get(user, "address_id")
addr = yield get(ADDRS, aid)
return addr["zip"]
print(zip_for(1), zip_for(2))
Just(value='N1 9GU') Nothing()
Limit: a generator can resume only once. So this trick works for Maybe, Result, Writer, and Reader. It breaks for List, whose bind calls the rest many times.
These four trip up almost every student. Each one has a quick tell.
print(Just(4).map(lambda x: Just(x + 1))) print(Just(4).bind(lambda x: Just(x + 1)))
Just(value=Just(value=5)) Just(value=5)
Tell: a box inside a box. If the step returns a box, use bind.
r = Just(4).bind(lambda x: x + 1) # returns 5, not Just(5) r.bind(lambda y: Just(y * 2))
AttributeError: 'int' object has no attribute 'bind'
Tell: the chain breaks one step later than the bug. Every step given to bind must return a box. Wrap plain results with unit, or use map.
Maybe and List look like containers. Reader and State do not. Their “box” is a function waiting for a config or a state. Nothing is stored until you run it.
Better words: a monad is a context for a computation, with a rule for chaining steps in that context.
Haskell uses a monad for I/O, so the two get mixed up. But Maybe, Result, List, Writer, Reader, and State are all pure. Same input, same output, nothing touched outside.
A monad models an effect as a plain value. That is why you can test it with ==.
Before you write .bind(step), ask: does step return the same kind of box I started with? If yes, bind. If it returns a plain value, map.
Where the word “monad” comes from, and why the laws look the way they do.
Godement (“standard construction”), Huber, Kleisli, Eilenberg–Moore study monads in algebraic topology.
Eugenio Moggi: monads model computational effects in programming language semantics.
Philip Wadler brings them to functional programming. Haskell adopts them for I/O.
A category 𝒞 has:
Laws
h ∘ (g ∘ f) = (h ∘ g) ∘ f
f ∘ idA = f = idB ∘ f
int, str, User).lambda x: g(f(x)).lambda x: x.compose = lambda g, f: lambda x: g(f(x)) identity = lambda x: x
Strictly, Haskell's category Hask needs care around non-termination. We treat pure, total Python functions as a sketch of Set.
A functor F : 𝒞 → 𝒟 maps each object A to F A, and each arrow f : A → B to F f : F A → F B, so that
F(idA) = idFA F(g ∘ f) = F g ∘ F f
An endofunctor maps a category to itself. list, Maybe, Reader[E] are endofunctors on Py. fmap is the arrow part.
A natural transformation α : F ⇒ G is a family of arrows αA : F A → G A such that for every f : A → B:
G f ∘ αA = αB ∘ F f
Python example: head_opt : list ⇒ Maybe. Mapping then taking the head equals taking the head then mapping. Naturality = “works the same for any element type”.
A monad on 𝒞 is an endofunctor T : 𝒞 → 𝒞 with two natural transformations:
η : Id ⇒ T (unit)
μ : T∘T ⇒ T (join / multiplication)
Coherence laws
μ ∘ Tμ = μ ∘ μT (associativity)
μ ∘ Tη = idT = μ ∘ ηT (unit)
This is exactly the shape of a monoid (M, ⋅, e), with ⋅ replaced by μ and e by η. Hence the slogan: “a monad is a monoid in the category of endofunctors.”
Both paths through each diagram give the same arrow. For lists: flattening a list of lists of lists inside-first or outside-first gives the same list.
The two definitions carry the same information. Each can be built from the other.
From (η, μ) to bind
m >>= f = μ(T f (m))
Map f inside to get T(T B), then flatten.
From bind to (η, μ)
μ(mm) = mm >>= id T f (m) = m >>= (η ∘ f)
Under these maps, the three bind laws hold iff the functor laws plus the square and triangles hold. That is a short, standard proof by rewriting.
def join(mm): # mu
return mm.bind(lambda m: m)
def fmap(f, m, unit): # T f
return m.bind(lambda x: unit(f(x)))
def bind_via_join(m, f, unit):
return join(fmap(f, m, unit))
print(join(Just(Just(3))), join(Just(Nothing())))
Just(value=3) Nothing()
| Monad | η | μ |
|---|---|---|
| Maybe | Just | Just(Just x) ↦ Just x |
| List | [x] | concat |
| Writer | (x, e) | ((x, w₁), w₂) ↦ (x, w₂⋅w₁) |
| Reader | const x | λe. f(e)(e) |
Kleisli category 𝒞T of a monad T:
(f >=> g)(x) = f(x) >>= g
Monad laws, restated
η >=> f = f f >=> η = f
(f >=> g) >=> h = f >=> (g >=> h)
The three odd-looking laws are just identity + associativity for composing effectful functions. A monad is precisely what makes A → T B functions compose like normal ones.
def kleisli(f, g): # f >=> g
return lambda x: f(x).bind(g)
pipeline = kleisli(kleisli(parse_int, positive), safe_recip)
print(pipeline("8"), pipeline("0"))
Ok(value=0.125) Err(error='must be > 0, got 0')
Moggi's insight: a program A → B with effects is a Kleisli arrow A → T B. Pick T to pick the effect.
Adjunction F ⊣ G with F : 𝒞 → 𝒟, G : 𝒟 → 𝒞 means a natural bijection
Hom𝒟(F A, B) ≅ Hom𝒞(A, G B)
with unit η : Id ⇒ GF and counit ε : FG ⇒ Id.
Theorem (Huber 1961). Every adjunction gives a monad on 𝒞:
T = G F, η, μ = G ε F
Conversely (Kleisli, Eilenberg–Moore 1965), every monad arises from some adjunction.
| Adjunction | Monad GF |
|---|---|
| Free monoid ⊣ forget (Set ↔ Mon) | List |
| (– × S) ⊣ (S → –) (currying) | State S → (– × S) |
| Free pointed set ⊣ forget | Maybe 1 + – |
An Eilenberg–Moore algebra is a : T A → A with a ∘ η = id and a ∘ T a = a ∘ μ. For List, algebras are exactly monoids. sum on list[int] is one.
assert sum([sum(xs) for xs in [[1, 2], [3]]]) == sum([1, 2, 3]) # a.Ta = a.mu
Each level adds power. Every monad is also an applicative, and every applicative is also a functor.
| Level | Operation | Can the next step depend on the previous result? | Math |
|---|---|---|---|
| Functor | fmap : (A→B) → F A → F B | No second step at all | endofunctor |
| Applicative | ap : F(A→B) → F A → F B | No. Steps are fixed up front, so they can run in parallel. | lax monoidal functor |
| Monad | bind : M A → (A → M B) → M B | Yes. The next step is chosen from the value. | monoid in [𝒞, 𝒞] |
def validate_all(*results):
errs = [r.error for r in results if isinstance(r, Err)]
return Err("; ".join(errs)) if errs else Ok(tuple(r.value for r in results))
print(validate_all(parse_int("x"), parse_int("7"), positive(-1)))
Err(error="not a number: 'x'; must be > 0, got -1")
Monadic bind would stop at the first error. Applicative can collect them all.
Use the weakest interface that works. Less power means more freedom for the runtime: parallel runs, static analysis, better error reports.
| Python feature | Monad it resembles | Where the “bind” is |
|---|---|---|
| List / generator comprehensions | List | each nested for |
async / await | Future / Task (continuation) | x = await fut runs the rest once fut is done |
| Exceptions | Either (built into the language) | each statement, implicitly |
Optional[T] + early return | Maybe | the if x is None: return None line |
contextvars, dependency containers | Reader | implicit lookup of the environment |
Parser combinators (e.g. parsy) | State + Either + List | .bind / @generate |
returns (dry-python) gives typed Maybe, Result, IO, and RequiresContext with mypy support. parsy and pyparsing use monadic parsing.
Python has no type classes and no tail calls. Deep bind chains cost stack frames. Use the idea to shape APIs; do not force Haskell style on a team that does not want it.
Try each one on paper first. Uses Just, Nothing, and ListM from the earlier slides.
Just(3).bind(lambda x: Nothing()).bind(lambda y: Just(y * 2))
What is the result?
Nothing()
The second step returns Nothing(), so every later bind is skipped.
ListM([1, 2]).bind(lambda x: ListM([x, x * 10])).xs
What is the result?
[1, 10, 2, 20]
Each input gives two outputs, and bind concatenates them in order.
You pull three steps out of the middle of a long bind chain into a helper. Which law says the result will not change?
Associativity. Grouping the steps differently gives the same answer.
Why is map alone not enough to chain steps that can fail?
A failing step returns a box, so map gives a box in a box, F(F B). You need join to flatten it. bind = map then join.
What does μ (join) do for Maybe on each of the three inputs Just(Just(x)), Just(Nothing()), Nothing()?
Just(x), Nothing(), Nothing(). Only a full box inside a full box survives.
Given f : A → M B and g : B → M C, write their Kleisli composition in Python. What is its identity?
lambda x: f(x).bind(g). The identity is unit.
unit and bind.bind holds the plumbing: stop, branch, log, read, or pass state.| Monad | Type | Effect |
|---|---|---|
| Maybe | 1 + A | may be missing |
| Either | E + A | may fail with a reason |
| List | A* | many answers |
| Writer | A × W | adds to a log |
| Reader | E → A | reads config |
| State | S → A × S | reads and writes state |
| Term | Meaning |
|---|---|
| Functor | A box type with map (fmap) that obeys the two functor laws |
| Monad | A box type with unit and bind that obeys the three monad laws |
| unit / return / pure / η | Put a plain value into the box |
bind / >>= / flatMap | Run a step that returns a box, then flatten |
| join / μ | Flatten one layer: M(M A) → M A |
| Applicative | Between functor and monad: combine boxes whose steps do not depend on each other |
| Effect | The extra thing the box tracks: failure, many results, a log, config, state |
| Short-circuit | Skip the rest of a chain once a step fails |
| Term | Meaning |
|---|---|
| Do-notation | Syntax that hides the bind calls; in Python, generators or comprehensions |
| Category | Objects plus arrows that compose, with identities; composition is associative |
| Endofunctor | A functor from a category back to itself, like list on Python types |
| Natural transformation | A map F ⇒ G between functors that commutes with every fmap |
| Kleisli arrow | An effectful function A → M B |
Kleisli composition >=> | Chain two Kleisli arrows: lambda x: f(x).bind(g) |
| Adjunction F ⊣ G | A pair of functors; G∘F is always a monad |
| Monoid | A set with an associative operation and an identity, like (str, +, "") |