How three abstractions from category theory transform code – no math degree required.
1. The Problem: Nested None Checks and try/except Cascades
A typical problem:
def get_user(user_id: int) -> dict | None:
users = {1: {"name": "Alice", "email": "alice@example.com", "address_id": 42}}
return users.get(user_id)
def get_address(address_id: int) -> dict | None:
addresses = {42: {"city": "Berlin", "zip": "10115"}}
return addresses.get(address_id)
def get_user_city(user_id: int) -> str | None:
user = get_user(user_id)
if user is None:
return None
address_id = user.get("address_id")
if address_id is None:
return None
address = get_address(address_id)
if address is None:
return None
return address.get("city")
Three nested None checks for a simple query. This doesn't scale. What if there were a pattern that handles this nesting automatically?
2. Functor: Lifting a Function into a Context
The Intuition
A functor is a container that supports a map operation:
numbers = [1, 2, 3]
doubled = list(map(lambda x: x * 2, numbers)) # [2, 4, 6]
map takes a function A → B and applies it to every value inside the container, without changing the container itself.
"A functor is a mapping between categories. Given two categories, C and D, a functor F maps objects in C to objects in D — it's a function on objects." — Category Theory for Programmers, p. 93
Functor in Python: Maybe
A Maybe type that either contains a value (Just) or is empty (Nothing):
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeVar, Callable, Generic
A = TypeVar("A")
B = TypeVar("B")
@dataclass(frozen=True)
class Maybe(Generic[A]):
_value: A | None
@staticmethod
def just(value: A) -> Maybe[A]:
return Maybe(_value=value)
@staticmethod
def nothing() -> Maybe[A]:
return Maybe(_value=None)
@property
def is_nothing(self) -> bool:
return self._value is None
def map(self, f: Callable[[A], B]) -> Maybe[B]:
if self._value is None:
return Maybe.nothing()
return Maybe.just(f(self._value))
Applying functions to optional values without None checks:
name = Maybe.just("Alice")
upper = name.map(str.upper) # Maybe.just("ALICE")
length = name.map(len) # Maybe.just(5)
empty = Maybe.nothing()
result = empty.map(str.upper) # Maybe.nothing() – no error!
The Functor Laws
A functor must satisfy two laws:
"Aside from facilitating code reuse by bringing in all standard functions of simple types in a more complex context, map allows us to work in a way that is predictable." — Category Theory Illustrated, p. 221
Law 1: Identity – map(id) changes nothing:
x = Maybe.just(42)
assert x.map(lambda a: a) == x
Law 2: Composition – Mapping twice = mapping once with the composed function:
f = lambda x: x + 1
g = lambda x: x * 2
x = Maybe.just(5)
assert x.map(f).map(g) == x.map(lambda a: g(f(a)))
"fmap preserves composition: fmap (g . f) = fmap g . fmap f" — Category Theory for Programmers, p. 99
More Functors: Result
The Result type from article 2 is also a functor:
Ok(42).map(lambda x: x * 2) # Ok(84)
Err("not found").map(lambda x: x * 2) # Err("not found")
The error is passed through – no try/except needed.
3. Applicative: Combining Multiple Contexts
The Problem
A functor can apply a function to one value in context. But what about multiple values?
name = Maybe.just("Alice")
age = Maybe.just(30)
# How to combine into Maybe.just(("Alice", 30))?
Applicative: apply
An applicative is a functor with apply: a wrapped function is applied to a wrapped value:
def create_greeting(name: str) -> Callable[[int], str]:
return lambda age: f"Hello {name}, you are {age} years old!"
greeting = (
Maybe.pure(create_greeting)
.apply(Maybe.just("Alice"))
.apply(Maybe.just(30))
)
# → Maybe.just("Hello Alice, you are 30 years old!")
When one of the values is Nothing:
greeting = (
Maybe.pure(create_greeting)
.apply(Maybe.nothing())
.apply(Maybe.just(30))
)
# → Maybe.nothing() – automatically, no if-check!
Practical Example: Parallel Validation
"Applicatives are similar to monads; but rather than chaining monadic functions in series, an applicative allows you to combine monadic values in parallel." — Domain Modeling Made Functional, p. 225
While monads chain sequentially, applicatives can combine in parallel – ideal for validation:
@dataclass(frozen=True)
class Validation(Generic[A]):
_value: A | None
_errors: tuple[str, ...]
@staticmethod
def success(value: A) -> Validation[A]:
return Validation(_value=value, _errors=())
@staticmethod
def failure(*errors: str) -> Validation:
return Validation(_value=None, _errors=errors)
def apply(self, other: Validation) -> Validation:
if not self.is_success and not other.is_success:
return Validation.failure(*(self._errors + other._errors))
if not self.is_success:
return Validation.failure(*self._errors)
if not other.is_success:
return Validation.failure(*other._errors)
return Validation.success(self._value(other._value))
result = (
Validation.success(make_user)
.apply(validate_name("")) # ← Error 1
.apply(validate_age(-5)) # ← Error 2
.apply(validate_email("invalid")) # ← Error 3
)
# → Validation(errors=("Name too short", "Invalid age", "Invalid email"))
Three errors at once – instead of stopping at the first one.
4. Monad: Chained Computations with Context
The Problem That Functor and Applicative Don't Solve
result = get_user(1).map(get_address_id)
# → Maybe.just(Maybe.just(42)) ← nested!
map yields Maybe[Maybe[int]] instead of Maybe[int].
bind: The Monadic Operation
"A monad is just a programming pattern that allows you to chain 'monadic' functions together in series." — Domain Modeling Made Functional, p. 225
def bind(self, f: Callable[[A], Maybe[B]]) -> Maybe[B]:
if self._value is None:
return Maybe.nothing()
return f(self._value)
The difference from map:
- map: takes A → B, returns Maybe[B]
- bind: takes A → Maybe[B], returns Maybe[B] (no nesting!)
The Original Problem – Solved
city = (
get_user(1)
.bind(get_address_id)
.bind(get_address)
.bind(get_city)
)
# → Maybe.just("Berlin")
city = (
get_user(999) # ← User doesn't exist → Nothing
.bind(get_address_id) # skipped
.bind(get_address) # skipped
.bind(get_city) # skipped
)
# → Maybe.nothing()
Not a single None check. All error handling lives in bind.
The Monad Laws
1. Left identity: pure(a).bind(f) == f(a)
2. Right identity: m.bind(pure) == m
3. Associativity: m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g))
5. The Hierarchy: Functor → Applicative → Monad
"Every monad is an applicative functor and every applicative functor is a functor, all having their own properties and laws." — Haskell Functional Design and Architecture, p. 110
Every monad is a functor and an applicative:
| Situation | Abstraction | Why |
|---|---|---|
| Apply a function to one value in context | Functor (map) |
Simplest level |
| Combine multiple independent values | Applicative (apply) |
Collect all errors |
| Chained steps, each depending on the previous | Monad (bind) |
Sequential dependencies |
6. Result as Monad: Error Handling Without Exceptions
@dataclass(frozen=True)
class Ok(Generic[A]):
value: A
def map(self, f: Callable[[A], B]) -> Result:
return Ok(f(self.value))
def bind(self, f: Callable[[A], Result]) -> Result:
return f(self.value)
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
def map(self, f: Callable) -> Err[E]:
return self
def bind(self, f: Callable) -> Err[E]:
return self
Practical Example: User Registration
def register_user(raw_email: str, raw_age: str) -> Result:
return (
parse_email(raw_email)
.bind(lambda email:
parse_age(raw_age)
.bind(lambda age:
create_user(email, age)))
)
register_user("alice@example.com", "30")
# → Ok({"email": "alice@example.com", "age": 30, "status": "active"})
register_user("invalid", "30")
# → Err("Invalid email address")
7. Practical Example: Data Pipeline with Functors and Monads
A data pipeline processing CSV lines:
def process_line(line: str) -> Result:
return (
parse_line(line)
.bind(validate_value) # Monad: can fail
.map(normalize) # Functor: pure transformation
)
Clear separation:
- bind for steps that can fail (parsing, validation)
- map for pure transformations (normalization)
Try it yourself
A complete, self-contained Maybe example: map for pure transformations, bind for steps that can fail. Note: there is not a single None check in the pipeline code — context handling lives inside map/bind.
from dataclasses import dataclass
@dataclass(frozen=True)
class Just:
value: object
def map(self, f):
return Just(f(self.value))
def bind(self, f):
return f(self.value)
@dataclass(frozen=True)
class Nothing:
def map(self, f):
return self
def bind(self, f):
return self
def safe_div(a, b):
return Nothing() if b == 0 else Just(a / b)
def safe_sqrt(x):
return Nothing() if x < 0 else Just(x ** 0.5)
def pipeline(a, b):
return (
safe_div(a, b)
.bind(safe_sqrt) # Monad: can fail
.map(lambda x: round(x, 3)) # Functor: pure transformation
)
for a, b in [(16, 4), (10, 0), (-9, 1), (50, 2)]:
print(f"pipeline({a:>3}, {b}) → {pipeline(a, b)}")
Extension idea: swap Just/Nothing for Ok(value)/Err(reason) so the failure reason is carried through the pipeline.
Summary
| Abstraction | Operation | Signature | Purpose |
|---|---|---|---|
| Functor | map |
(A → B) → F[A] → F[B] |
Transform value in context |
| Applicative | apply |
F[A → B] → F[A] → F[B] |
Combine multiple contexts |
| Monad | bind |
(A → F[B]) → F[A] → F[B] |
Chain contexts sequentially |
What was achieved:
- No None-check boilerplate –
Maybe.bind()handles it automatically - No try/except cascades –
Result.bind()passes errors through - All errors at once –
Validation.apply()collects instead of stopping - Clear pipeline structure –
mapfor pure transformations,bindfor fallible steps
In the next article, we demonstrate with a complete practical example how these abstractions transform an if/else-heavy method into an elegant functional composition.
Sources
- Bartosz Milewski: Category Theory for Programmers, pp. 93–99, 111, 184–185, 317, 323, 345
- Boris Nikolić: Category Theory Illustrated, pp. 202–205, 221
- Scott Wlaschin: Domain Modeling Made Functional, pp. 215, 225
- Functional Programming in Kotlin by Tutorials, pp. 277, 317, 320, 345, 365
- Alexander Granin: Haskell Functional Design and Architecture, pp. 110, 182
Code Examples
src/maybe_functor.py– Maybe as functor with lawssrc/maybe_monad.py– Maybe as complete monad (map, apply, bind)src/result_monad.py– Result monad for error handlingsrc/validation.py– Applicative Validation (parallel error collection)src/data_pipeline.py– Practical example: CSV pipeline with functor and monad