Home Fp Basics Python Article 3
Part 3 Functional Programming & Category Theory

Functor, Applicative, Monad – Category Theory for Python Developers

Image: AI-generated


Functor, Applicative, Monad – Category Theory for Python Developers

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: Identitymap(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:

  1. No None-check boilerplateMaybe.bind() handles it automatically
  2. No try/except cascadesResult.bind() passes errors through
  3. All errors at onceValidation.apply() collects instead of stopping
  4. Clear pipeline structuremap for pure transformations, bind for 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

Code Examples

📄 As PDF