Category Theory for programmers
🔀 Chapter 5 · Arrows between containers

From one box
to another

Last chapter we got containersList, Option, Either. Now: how do you turn one into another, the same way, no matter what's inside? That's a natural transformation.

Forget the scary name for a minute. The whole idea is a recipe that converts one container into another — and the recipe doesn't peek inside.

You already know several. headOption turns a List into an Option. toList turns an Option into a List. reverse turns a List into a List (yes, that counts). What they share: they work the same way whether the container holds Int, String, or User. They can only shuffle the box; they can't look at what's in it and react.

That "can't look inside" rule has a name — naturality — and a beautiful little diagram that enforces it. Once you see the diagram, the whole concept clicks.

1A recipe that swaps one container for another

A few examples first — three real Scala functions you've probably used:

  • headOption : List[A] => Option[A] — "give me the first element, if there is one." Empty list → None; non-empty → Some(head).
  • toList : Option[A] => List[A]Some(x) becomes List(x), None becomes List().
  • reverse : List[A] => List[A] — same container in, same container out, just rearranged.

Notice the shape. Each one takes some F[A] and gives back a G[A], for every A. The thing inside is untouched — headOption doesn't multiply the number, doesn't uppercase the string, doesn't do anything to the contents at all. It restructures the box.

This is exactly the cast: a family of functions, one for each type A, all doing "the same conversion." We name the family with a single Greek letter — usually η ("eta") — and we write the whole thing as η : F ⇒ G or F ~> G. That's a natural transformation.

Gallery · click to apply Sees the conversion in action on a sample value
Pick one. The recipe runs on a sample value and shows you the result.
💡
The single name η stands for a whole family: one function η_A per type A, all sharing the same recipe. When we write headOption in Scala, the implicit [A] is doing exactly this — one definition, infinitely many concrete functions.

2The naturality square

Here's the test that decides whether a converter is actually a natural transformation. Suppose you have an F[A] and a plain function f : A → B. You want to end up with a G[B]. There are two obvious ways to get there:

  • Top route: F.map(f) first to get F[B], then convert with η to get G[B].
  • Bottom route: convert with η first to get G[A], then G.map(f) to get G[B].

The "natural" part says: both routes give the exact same answer. Always. For every f, every starting value, every type.

η_B ∘ F.map(f)  =  G.map(f) ∘ η_A

Interactive · walk both paths Pick η, pick f, pick a value · then trace
Pick a recipe, a function, and a value, then trace.
📐
The square commutes for every legit natural transformation — that's the definition. You'll never find a counterexample using headOption, toList, or reverse. Try as hard as you like. Section 3 shows what a non-natural converter looks like, and why the square breaks for it.

3Why "natural" — the no-peeking rule

Here's the punchline. The square is doing one job: it forbids the recipe from looking at what's inside the container.

Suppose someone hands you a "converter" weirdConvert : List[A] => Option[A] that does this: "if the elements happen to be Int, double the first one and wrap it in Some; otherwise return None." That's not a real recipe — it's a recipe that peeks. It treats Int specially.

Watch what happens when you put it on the square. Start with List(3) and f = x => x.toString. The two routes diverge — and that's exactly how the diagram catches a cheat.

Counterexample · a recipe that peeks Watch the naturality square break

weirdConvert(xs) = if every element is an Int, return Some(head · 2); otherwise None. We send a List(3) around the square with f = _.toString.

So "natural" means uniform across all types. The Scala flavour you may know this by is parametric polymorphism — a function with signature [A] => F[A] => G[A] literally cannot inspect A, because it has no idea what A is. Haskell calls the corresponding guarantee a free theorem: the naturality square just holds for any parametrically polymorphic function — you get it for free, from the type signature alone. No proof needed.

⚠️
Reality check: Scala lets you cheat with runtime type tests (isInstanceOf, ClassTag, pattern matching on Any). If your converter does that, it's not natural — and the square is the diagnostic that proves it.

4The same idea, in code

Both languages let you name the shape "for every A, take F[A] and return G[A]" as a single type. In Scala 3 it's a polymorphic function type; in Haskell it's a forall. Either way: the type alone says you can't peek.

🧩
The forall a. in Haskell (or the [A] => in Scala 3) is doing real work: it's the compiler-enforced version of "the recipe doesn't peek." That single quantifier is why parametricity gives you the naturality square for free.

5Check yourself

3 questions · instant feedback 0 / 3

6What's next

So far we have functors (containers that respect composition) and natural transformations (ways to swap one container for another without peeking). One missing piece keeps showing up in every real program: chaining steps that each produce more context.

Reading a file gives you a Future[String]. Parsing that string gives you another Future[Json]. You want one big Future at the end, not Future[Future[Json]]. The pattern of "flatten as you go" is the headline of next chapter — and it has a famous name.

🌀
Up next · Chapter 6
Monads
Functors plus the ability to chain context-producing steps. The payoff chapter — where flatMap, for-comprehensions, and do-notation all stop being mysteries.