more below ↓

Monads, in Python

One pattern for chaining steps that carry extra baggage:
missing values, errors, many answers, logs, config, and state.

Part 1 · Practice

Maybe, Result, List, Writer, Reader, State. All in plain Python you can run.

Part 2 · Laws

Three laws that make chaining safe to refactor, and how to test them.

Part 3 · Theory

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.

Roadmap WHERE WE GO

  1. The problem: plumbing between steps
  2. Intuition: boxes on a conveyor belt
  3. Functors: map inside a box
  4. Monads: unit + bind
  5. The three monad laws
  6. Maybe — values that may be missing
  7. Proving the laws for Maybe
  8. Result — errors with a reason
  9. List — many possible answers
  10. Writer — values plus a log
  11. Reader — shared config
  12. State — threading state, purely
  13. Do-notation with generators
  14. Common mistakes
  15. Math: categories & functors
  16. Math: natural transformations
  17. Math: monad as (T, η, μ)
  18. Math: the Kleisli category
  19. Math: adjunctions make monads
  20. Applicative, and where monads show up in real Python
  21. Check yourself, summary, glossary

The Problem: Plumbing Between Steps MOTIVATION

Find a user's zip code. Every lookup can fail. The real logic is three lines. The rest is plumbing.

Without a pattern

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")

With a monad

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.

Intuition: Boxes on a Conveyor Belt BEFORE THE DEFINITIONS

Keep one picture in your head for the rest of the talk. The words box, map, and bind will all hang on it.

The picture

  • Each step of your program is a station on a belt. It takes a plain item and hands back an item in a box.
  • The box carries extra context: “maybe empty”, “failed, here is why”, “several items”, “plus a log line”.
  • Between stations stands a worker called bind. It opens the box, follows that box's rule, and feeds the item to the next station.
  • An empty box? The worker skips every later station. That is the whole Maybe monad.
Step shapeAfter one step
mapA → BF B
map with a boxing stepA → F BF (F B)
bindA → F BF B

See the nesting problem with plain lists

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.

Step 1: Functors — map Inside a Box FMAP

A 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.

You already use functors

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.

Step 2: A Monad = unit + bind DEFINITION

A 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.
  • The box decides the rules. Stop early? Run many times? Add to a log?

Picture it

M a f : a → M b M b unwrap a flatten m.bind(f) : the box's rules run between steps

Minimal Python interface: a static unit(x) and a method bind(self, f). map comes for free: m.bind(lambda x: unit(f(x))).

The Three Monad Laws WHY CHAINS ARE SAFE

Write m >>= f for m.bind(f). Every lawful monad must satisfy:

LawEquationPlain meaning
Left identityunit(a) >>= f  =  f(a)Wrapping then binding adds nothing.
Right identitym >>= unit  =  mBinding to “just wrap it” changes nothing.
Associativity(m >>= f) >>= g  =  m >>= (λx. f(x) >>= g)Grouping of steps does not matter.

Test them like any property

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")

Why you care

  • Associativity lets you extract a helper from the middle of a chain. Behavior stays the same.
  • Identity laws let you add or drop a trivial wrap freely.
  • In Part 3 we will see these are exactly the category laws in disguise.

Maybe: Values That May Be Missing SHORT-CIRCUIT

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.

Proving the Laws for Maybe BY CASES

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.

1. Left identity

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.

2. Right identity

Case m = Nothing():

Nothing().bind(unit) = Nothing()  ✓

Case m = Just(v):

Just(v).bind(unit) = unit(v) = Just(v)  ✓

3. Associativity

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. ✓

The pattern to reuse

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.

Result (Either): Errors With a Reason RAILWAY

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

Ok rail Err rail parse positive recip

This is Python's try/except made into a value. You can store it, return it, and pass it around.

List: Many Possible Answers NONDETERMINISM

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)]

Surprise: you already knew this one

[(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.

Writer: Values Plus a Log ACCUMULATE

@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.

Reader: Shared Config Without Globals DEPENDENCY INJECTION

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.

State: Threading State, Purely NO MUTATION

@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 → –).

Do-Notation With Generators NO MORE LAMBDA STAIRS

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.

Common Mistakes AND HOW TO SPOT THEM

These four trip up almost every student. Each one has a quick tell.

1. Using map where you need bind

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.

2. A bind step that forgets the box

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.

3. “A monad is a container”

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.

4. “Monads are about side effects”

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 ==.

Quick self-check

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.

Part 3 · The Math

Where the word “monad” comes from, and why the laws look the way they do.

1958–1965

Godement (“standard construction”), Huber, Kleisli, Eilenberg–Moore study monads in algebraic topology.

1989–1991

Eugenio Moggi: monads model computational effects in programming language semantics.

1992–1995

Philip Wadler brings them to functional programming. Haskell adopts them for I/O.

Categories OBJECTS + ARROWS

A category 𝒞 has:

  • objects A, B, C, …
  • arrows f : A → B, with a set Hom(A, B) for each pair
  • composition g ∘ f : A → C for f : A → B, g : B → C
  • an identity idA : A → A for each object

Laws

h ∘ (g ∘ f) = (h ∘ g) ∘ f

f ∘ idA = f = idB ∘ f

The one we care about: Py

  • Objects: Python types (int, str, User).
  • Arrows: pure functions between them.
  • Composition: lambda x: g(f(x)).
  • Identity: 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.

Functors and Natural Transformations MAPS BETWEEN MAPS

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

F AG A F BG B αAαB F fG 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 Is (T, η, μ) THE CATEGORICAL DEFINITION

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.”

associativity square T³T² T²T Tμμ μTμ unit triangles TT²T T ηTTη μ idid

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.

Programmer's Monad = Mathematician's Monad BIND ↔ JOIN

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ημ
MaybeJustJust(Just x) ↦ Just x
List[x]concat
Writer(x, e)((x, w₁), w₂) ↦ (x, w₂⋅w₁)
Readerconst xλe. f(e)(e)

The Kleisli Category WHY THE LAWS ARE CATEGORY LAWS

Kleisli category 𝒞T of a monad T:

  • Objects: same as 𝒞.
  • Arrows A ⇝ B: ordinary arrows A → T B (“effectful functions”).
  • Identity: ηA : A → T A.
  • Composition (the “fish”):

(f >=> g)(x) = f(x) >>= g

Monad laws, restated

η >=> f = f    f >=> η = f

(f >=> g) >=> h = f >=> (g >=> h)

So what?

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.

Where Monads Come From: Adjunctions F ⊣ G

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.

AdjunctionMonad GF
Free monoid ⊣ forget (Set ↔ Mon)List
(– × S) ⊣ (S → –) (currying)State S → (– × S)
Free pointed set ⊣ forgetMaybe 1 + –

Algebras of a monad

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

Functor → Applicative → Monad POWER VS. ANALYSIS

Each level adds power. Every monad is also an applicative, and every applicative is also a functor.

LevelOperationCan the next step depend on the previous result?Math
Functorfmap : (A→B) → F A → F BNo second step at allendofunctor
Applicativeap : F(A→B) → F A → F BNo. Steps are fixed up front, so they can run in parallel.lax monoidal functor
Monadbind : M A → (A → M B) → M BYes. The next step is chosen from the value.monoid in [𝒞, 𝒞]

Applicative example: validate all fields

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.

Rule of thumb

Use the weakest interface that works. Less power means more freedom for the runtime: parallel runs, static analysis, better error reports.

Monads Hiding in Everyday Python IN THE WILD

Python featureMonad it resemblesWhere the “bind” is
List / generator comprehensionsListeach nested for
async / awaitFuture / Task (continuation)x = await fut runs the rest once fut is done
ExceptionsEither (built into the language)each statement, implicitly
Optional[T] + early returnMaybethe if x is None: return None line
contextvars, dependency containersReaderimplicit lookup of the environment
Parser combinators (e.g. parsy)State + Either + List.bind / @generate

Libraries

returns (dry-python) gives typed Maybe, Result, IO, and RequiresContext with mypy support. parsy and pyparsing use monadic parsing.

When not to

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.

Check Yourself ANSWER, THEN CLICK

Try each one on paper first. Uses Just, Nothing, and ListM from the earlier slides.

Q1

Just(3).bind(lambda x: Nothing()).bind(lambda y: Just(y * 2))

What is the result?

Show answer
Nothing()

The second step returns Nothing(), so every later bind is skipped.

Q2

ListM([1, 2]).bind(lambda x: ListM([x, x * 10])).xs

What is the result?

Show answer
[1, 10, 2, 20]

Each input gives two outputs, and bind concatenates them in order.

Q3

You pull three steps out of the middle of a long bind chain into a helper. Which law says the result will not change?

Show answer

Associativity. Grouping the steps differently gives the same answer.

Q4

Why is map alone not enough to chain steps that can fail?

Show answer

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.

Q5

What does μ (join) do for Maybe on each of the three inputs Just(Just(x)), Just(Nothing()), Nothing()?

Show answer

Just(x), Nothing(), Nothing(). Only a full box inside a full box survives.

Q6

Given f : A → M B and g : B → M C, write their Kleisli composition in Python. What is its identity?

Show answer

lambda x: f(x).bind(g). The identity is unit.

Summary TAKE-AWAYS

Remember

  • A monad is a box M with unit and bind.
  • bind holds the plumbing: stop, branch, log, read, or pass state.
  • Three laws: two identities and associativity.
  • Math view: (T, η, μ), a monoid of endofunctors.
  • Kleisli view: effectful functions that compose.
MonadTypeEffect
Maybe1 + Amay be missing
EitherE + Amay fail with a reason
ListA*many answers
WriterA × Wadds to a log
ReaderE → Areads config
StateS → A × Sreads and writes state

Further reading

  • Moggi, Notions of Computation and Monads (1991)
  • Wadler, Monads for Functional Programming (1995)
  • Mac Lane, Categories for the Working Mathematician, ch. VI
  • Milewski, Category Theory for Programmers

Glossary ONE PAGE OF WORDS

TermMeaning
FunctorA box type with map (fmap) that obeys the two functor laws
MonadA box type with unit and bind that obeys the three monad laws
unit / return / pure / ηPut a plain value into the box
bind / >>= / flatMapRun a step that returns a box, then flatten
join / μFlatten one layer: M(M A) → M A
ApplicativeBetween functor and monad: combine boxes whose steps do not depend on each other
EffectThe extra thing the box tracks: failure, many results, a log, config, state
Short-circuitSkip the rest of a chain once a step fails
TermMeaning
Do-notationSyntax that hides the bind calls; in Python, generators or comprehensions
CategoryObjects plus arrows that compose, with identities; composition is associative
EndofunctorA functor from a category back to itself, like list on Python types
Natural transformationA map F ⇒ G between functors that commutes with every fmap
Kleisli arrowAn effectful function A → M B
Kleisli composition >=>Chain two Kleisli arrows: lambda x: f(x).bind(g)
Adjunction F ⊣ GA pair of functors; G∘F is always a monad
MonoidA set with an associative operation and an identity, like (str, +, "")