Category Theory for programmers
⛓ Chapter 8 · Categories so thin they fit on a Hasse diagram

Every partial order
is secretly a category

Whenever you compare two things — types, sets, numbers — and ask "is this one smaller than that one?", you're walking around inside a category. A very thin one, with at most one arrow between any two objects. Let's draw it.

After all the action of monads and Kleisli, here's a chapter that feels almost gentle. We're going to take one of the oldest, most ordinary ideas in math — the partial order — and notice that it's a category in disguise. Then we'll watch this disguise show up everywhere in programming.

Quick road map:

  • Recall what a partial order ≤ is, plainly.
  • Notice that "≤" plus a few rules literally is a category. A "thin" one.
  • Draw posets as Hasse diagrams — no clutter, just the bones.
  • Spot the same shape in subtyping, lattices, CRDTs, and program analysis.
  • Meet meet and join. Then hear the word "Galois connection" without panicking.

By the end you'll know why so many comparisons in programming end up using categorical machinery without anyone ever calling it that.

1What's a partial order, again?

A partial order is a relation that tells you when one thing is "smaller than or equal to" another. The catch is the word partial: some pairs of things just aren't comparable. Pick "is an ancestor of" in a family tree — your two cousins are neither one above the other.

Three rules are all it takes:

  • Reflexivea ≤ a. Everything is at least as small as itself.
  • Transitive — if a ≤ b and b ≤ c, then a ≤ c. Smallness chains.
  • Antisymmetric — if a ≤ b and b ≤ a, then a = b. No two distinct things are equally small.

A set with one of these relations is called a poset (partially ordered set). Examples you already know:

  • Integers under — every pair is comparable (a "total" order, which is a special case).
  • Sets under inclusion {a} ⊆ {a, b}, but {a} and {b} aren't comparable.
  • Naturals under divisibility3 | 12, but 4 and 6 don't divide each other.
  • Types under "is a subtype of" — Dog <: Animal, but Dog and String are unrelated.
🔗
That last bullet is doing a lot of work. Subtyping is a partial order. Variance, type bounds, "least upper bound during type inference" — every Java/Scala feature you've cursed at lives inside a poset. We'll come back to this in section 4.

2Every poset is a category

Here's the trick. Take a poset. Treat each element as an object. Now decree this rule about arrows: there's exactly one arrow from a to b if a ≤ b, and no arrow otherwise.

That's it. We just built a category from a partial order. Let's check the category laws fall out for free.

  • Composition — if there's an arrow a → b and an arrow b → c, then by definition a ≤ b and b ≤ c. Transitivity gives us a ≤ c, which gives us the composite arrow a → c. Composition is transitivity.
  • Identity — every object a needs a "do nothing" arrow a → a. Reflexivity gives us a ≤ a, so the arrow exists. Identity is reflexivity.
  • Associativity — there's at most one arrow between any two objects, so grouping doesn't even come up. Anything you group reduces to the same arrow.

A category where there's at most one arrow between any two objects has a name: it's called thin. Posets are exactly the thin categories whose arrows encode an order.

⚠️
Where did antisymmetry go? Composition and identity used reflexivity and transitivity. Antisymmetry is the rule that says round-trip arrows force equality: if a → b and b → a both exist, then a = b. Drop antisymmetry and you still get a category — just one where distinct objects can be mutually below each other. That weaker structure is called a preorder.

3Hasse diagrams — drawing thin categories

Drawing every arrow of a poset-as-category gets ugly fast. The subset poset of {a, b, c} has 8 elements and 19 non-identity arrows. Most of them are noise — implied by transitivity from a smaller set of covering arrows.

A Hasse diagram is the bare skeleton. Just the covering arrows (the ones you can't get from transitivity), drawn vertically with smaller elements at the bottom. The direction of "≤" is just "go up." Cleaner.

Here's the subset lattice of {a, b, c}. Click a node to make it your "A". Click another to make it "B". The widget then highlights two things: the least upper bound (smallest thing above both — their join) and the greatest lower bound (largest thing below both — their meet). You can also see the principal up-set (everything ≥ your single pick) and down-set (everything ≤ it).

Interactive · subset lattice of {a, b, c} click one node for up/down sets · click two for meet & join
Selection
click a node
Join — A ∨ B (least upper bound)
Meet — A ∧ B (greatest lower bound)
For single pick
19 arrows total · 12 hidden by transitivity
💡
You're staring at a category. The eight subsets are objects. The covering arrows are a minimal generating set — every other arrow (like ∅ → {a, b, c}) follows from composing covering arrows. Transitivity does all the heavy lifting.

4Subtyping is a poset

Java's class hierarchy. Scala's type lattice. TypeScript's "assignability" rules. Every subtype relation <: is a partial order on types — and therefore a category.

  • Reflexive — every type is a subtype of itself. String <: String.
  • TransitiveDog <: Mammal and Mammal <: Animal give you Dog <: Animal.
  • Antisymmetric — if two types are mutually subtypes, they're the same type (mostly; languages cheat slightly).

The "arrow from S to T when S <: T" reading lines up with how subtyping is used at the value level. If you have a Dog and the API wants an Animal, you don't have to convert anything — you just walk along the arrow. That's the Liskov substitution principle, sneakily.

Variance is now easy to phrase. Given a type constructor F[_]:

  • Covariant — preserves the direction of the arrow. S <: T implies F[S] <: F[T]. Think Scala's List[+A]: a List[Dog] is a List[Animal].
  • Contravariant — flips the arrow. S <: T implies F[T] <: F[S]. Function arguments work this way: Animal => X is a Dog => X (an animal-handler also handles dogs).
  • Invariant — refuses to relate them at all. Array[Dog] and Array[Animal] are incomparable.

The names are Latin bookkeeping about direction. Co- means "together, with" — a covariant F varies with the subtype arrow: feed it Dog <: Animal and the result points the same way, F[Dog] <: F[Animal]. Contra- means "against, opposite" — a contravariant F varies against the arrow, spitting it back reversed: F[Animal] <: F[Dog]. (The "vari-" is just "varies".) So the question to ask of any F[_] is simply: when I push a subtype arrow through it, does the arrow come out pointing the same way (co) or flipped (contra)? Invariant means the arrow doesn't come out at all.

💡
Why is Array invariant when List is covariant? Same element types — the difference is mutability. List is read-only, so a List[Dog] viewed as a List[Animal] can only hand you dogs, which are animals. Safe. But Array lets you write. If Array[Dog] <: Array[Animal] were allowed, you could alias a dog-array as an animal-array and store a Cat into it — then read it back out as a Dog. Every line looks type-correct; the corruption is the aliasing. To stay sound the compiler forbids the relation entirely, making the two incomparable. The rule: a parameter you only read can be covariant (output position), one you only write can be contravariant (input position), one you do both to is stuck at invariant — which is exactly Array's apply/update pair.

Type inference, when it stares at val xs = List(dog, cat) and has to pick the element type, walks up the lattice to find the smallest common ancestor. The next widget shows the move.

Interactive · subtyping ladder · pick two types find their least upper bound (smallest common ancestor)
least upper bound: Number
💡
This is literally what your compiler does. Faced with branches of an if that return different types, type inference computes the join — the smallest type that fits both. if (cond) dog else cat infers Animal because that's the LUB.

5Lattices — posets with meet and join

A lattice is a poset where every pair of elements has a "best common upper bound" and a "best common lower bound." Those two operations get short, opinionated names:

  • Join a ∨ b — the least upper bound (LUB). The smallest thing that's above both a and b.
  • Meet a ∧ b — the greatest lower bound (GLB). The largest thing that's below both.

You already know lattices in disguise:

  • Subsets ordered by ⊆: join is union, meet is intersection. {1,2} ∨ {2,3} = {1,2,3}; {1,2} ∧ {2,3} = {2}.
  • Naturals ordered by divisibility: join is lcm, meet is gcd. 12 ∨ 18 = 36; 12 ∧ 18 = 6. (The most ancient algorithm shows up again.)
  • Booleans: join is ||, meet is &&. false ≤ true.
Interactive · meet & join calculator flip between subset and divisibility lattices · pick two elements
A
B
Join · A ∨ B
Meet · A ∧ B
🪐
Callback to Chapter 3. In the thin category of a poset, meet is a product and join is a coproduct. The product of a and b is the universal thing with arrows down to both — exactly the GLB. Coproduct flips it. Products and coproducts you saw with types aren't a different game; they're the same game played on a wider category.

6Galois connections — the adjunctions of poset land

Here's a pair of functions between two posets that "almost invert" each other. Take posets P and Q, and two monotone (order-preserving) functions f : P → Q and g : Q → P. They form a Galois connection when, for all a ∈ P and b ∈ Q:

f(a) ≤ b   ⇔   a ≤ g(b)

Read it twice. f(a) is below b in Q exactly when a is below g(b) in P. The is the whole point — both directions must hold. f and g aren't inverses (they don't have to round-trip back to the same value), but they're tied by this single bi-conditional.

Where you've seen this:

  • Abstract interpretation. A static analyzer wants to reason about a program without running it. It picks an abstract domain (say, "sign" — positive/negative/zero) and defines an abstraction function α (concrete value to its sign) and a concretization function γ (sign to the set of concrete values matching). Those two form a Galois connection between the concrete-states poset and the abstract-states poset. That connection is what makes the analysis sound.
  • Floor and ceiling. ⌊·⌋ : ℝ → ℤ and the inclusion ℤ ↪ ℝ form a Galois connection: ⌊r⌋ ≤ n ⇔ r ≤ n. Floor is the right adjoint of inclusion.
  • Substring search. "Match" and "extend match" relate string positions in the same shape.

The we wrote down is the soul of an adjunction. When we get to Chapter 12, you'll see adjunctions everywhere — and Galois connections are exactly adjunctions specialized to thin categories. Same shape, fewer arrows to track.

🔮
Foreshadow. In Ch.12 the general adjunction will read Hom(F a, b) ≅ Hom(a, G b) — a natural bijection between two hom-sets. In a poset, hom-sets are either empty or contain one arrow, so the bijection collapses to "both sides have an arrow exactly when..." which is our f(a) ≤ b ⇔ a ≤ g(b). Posets are the gentle preview of adjunctions.

7Where you've already seen this

Once you've named the pattern, you'll keep finding it. Four quick stops:

Subtyping

Already covered above, but worth restating: every modern type system's subtyping rules form a lattice (or close to one). Type inference walks it; variance describes how arrows lift through type constructors. Every "where does this type go?" question is a question about a poset.

CRDTs (Conflict-free Replicated Data Types)

Distributed systems need to merge concurrent edits from different replicas without conflict. The trick: make each replica's state an element of a lattice, and define the merge as the join. Because join is commutative, associative, and idempotent, you can merge in any order, any number of times — and converge to the same state. A grow-only set merges by union. A version vector merges by element-wise max. Lattice theory is the entire theoretical backbone.

Abstract interpretation

Mentioned in the Galois section but worth a beat. Program analyzers approximate runtime behavior using lattices of "abstract states." Joining states corresponds to merging control-flow branches; fixed-point iteration up the lattice computes the analysis result. The whole field is applied poset theory.

Type inference / unification

Hindley-Milner unification, Scala's type inference, OCaml's — they all walk a lattice of types under "is more specific than." When two constraints meet (say, α ≤ Int and α ≤ Double), unification finds the meet. The "occurs check" is the antisymmetry rule kicking in.

8The same idea, in code

Two languages, same shape. A PartialOrder typeclass gives you ; a Lattice typeclass extends it with meet and join. Instances for sets and for naturals-under-divisibility are direct.

9Check yourself

3 questions · instant feedback 0 / 3
Up next · Chapter 9
Function Types & Exponentials
When a function itself becomes a first-class citizen of the category. Currying, exponentials, and why A × B → C ≅ A → (B → C) is structural.