Last chapter we got containers — List, 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.
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.
headOption in Scala, the implicit [A] is doing exactly this — one definition, infinitely many concrete functions.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:
F.map(f) first to get F[B], then convert with η to get G[B].η 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
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.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.
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.
isInstanceOf, ClassTag, pattern matching on Any). If your converter does that, it's not natural — and the square is the diagnostic that proves it.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.
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.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.
flatMap, for-comprehensions, and do-notation all stop being mysteries.