Chapter 7 · Part III

Combinators: Building by Composition

Give a type a few primitives and a few ways to combine them that return the same type, and you can build unbounded behavior from a fixed vocabulary — snapping parts together instead of rewiring them.

Part II built a core out of trustworthy parts. But a pile of trustworthy parts is not a system; it is inventory. Part III is the bridge — how to assemble those parts into larger behavior — and it leans, deliberately, toward the flexibility half of the book without yet leaving the safety of the type system. This first chapter is about the most powerful assembly technique that stays fully typed: combinators.

The idea is small and has enormous reach. Pick a type. Provide a handful of primitive values of that type, and a handful of combining forms — operations that take values of the type and return a new value of the same type. Because the output of a combining form is itself a value of the type, it can be fed straight back into another. That single property, closure, is what turns a few pieces into an open-ended construction kit.

7.1 A Closed Interface

You already use combinators, even if not by that name. Numbers are a combinator system: the primitives are literals, the combining forms are +, *, and the rest, and because adding two numbers yields a number, you can nest arithmetic to any depth — (a + b) * (c - d) — without the language needing a special rule for each shape of expression. Strings concatenate the same way. The reason these feel effortless is exactly the closure property: the result of a combination is the same kind of thing as its inputs, so combinations compose without limit.

The design move of this chapter is to build our own such systems, deliberately, for the domain. A combinator library has two parts and a rule:

  • Primitives — the simplest values of the type, the atoms you start from.
  • Combining forms — operations that take values of the type and produce a new one.
  • The closure rule — every combining form returns the same type it consumes, so results are inputs to further combination.

Get those three right and the interface is closed: from outside, everything is a value of the one type, whether it is a lone atom or a deep tree of combinations. Callers compose without caring which they hold.

7.2 A Worked Algebra: Specifications

Take a concrete need from the running system: expressing which orders qualify for something — free shipping, a promotion, a fraud review. These conditions multiply and recombine endlessly ("domestic orders over $100", "either a loyalty member or a first-time buyer, but not a reseller"), so rather than write a bespoke boolean function for each, we build a small closed algebra of conditions. The type is a specification over some item: an object that can say whether the item satisfies it.

-- The open interface: any condition on a T is a Spec[T].
deferred class Spec[T]
  feature
    holds(item: T): Boolean do end        -- each concrete spec overrides this
end

The combining forms are themselves specifications, each holding sub-specifications and delegating to them. This is where closure lives: Both[T] takes two Spec[T] and is a Spec[T].

class Both[T] inherit Spec[T]
  create make(a: Spec[T], b: Spec[T]) do left := a  right := b end
  feature
    left: Spec[T]
    right: Spec[T]
    holds(item: T): Boolean
      do
        result := left.holds(item) and right.holds(item)
      end
end

class Either[T] inherit Spec[T]
  create make(a: Spec[T], b: Spec[T]) do left := a  right := b end
  feature
    left: Spec[T]
    right: Spec[T]
    holds(item: T): Boolean
      do
        result := left.holds(item) or right.holds(item)
      end
end

class Negate[T] inherit Spec[T]
  create make(s: Spec[T]) do inner := s end
  feature
    inner: Spec[T]
    holds(item: T): Boolean
      do
        result := not inner.holds(item)
      end
end

Then the primitives — the atoms specific to the domain, each a plain condition on a Placed order:

class Over_Amount inherit Spec[Placed]
  create make(m: Money) do threshold := m end
  feature
    threshold: Money
    holds(item: Placed): Boolean do result := item.total >= threshold end
end

class Domestic inherit Spec[Placed]
  create make() do end
  feature
    holds(item: Placed): Boolean do result := item.ships_domestically end
end

Now the combinations. "Domestic orders over the free-shipping floor" is not a new function to write — it is an expression in the algebra, assembled from parts that already exist:

-- free_ship_floor: Money, in scope
let free_shipping: Spec[Placed] :=
  create Both[Placed].make(
    create Over_Amount.make(free_ship_floor),
    create Domestic.make())

if free_shipping.holds(order) then
  -- waive the shipping fee
end

And because every combining form returns a Spec[Placed], the result nests without limit: Either the free-shipping spec Or a loyalty spec, and Negate a reseller spec — an arbitrarily deep condition built entirely from the same three combinators and a handful of primitives. No new machinery per combination; the algebra was the machinery.

7.3 Why Closure Is the Whole Trick

It is worth being precise about what closure bought, because it is easy to undervalue. Contrast the algebra with the way eligibility is usually coded — one growing function with a branch per case:

-- Edit-to-extend: every new rule reopens this function and its tests.
qualifies_for_free_shipping(o: Placed): Boolean
  do
    if o.total >= free_ship_floor and o.ships_domestically then
      result := true
    elseif o.is_loyalty_member and o.total >= loyalty_floor then
      result := true
    -- ...and on, and on, edited in place forever
    end
  end

Every new condition here means editing a function that already works — the exact rigidity Chapter 1 called the second hardness. The combinator version never edits: a new condition is a new expression, composed from existing parts, and the parts are untouched. Complexity moves out of the code and into the data — the tree of combined specs — where it can be built, inspected, even assembled at runtime from configuration, without reopening a single function.

That is the payoff of a closed interface. A fixed, finite vocabulary yields an infinite space of behaviors, because the vocabulary composes with itself. You stop writing behaviors and start writing them down in a language you designed. It is the first glimpse of a theme that dominates Part IV: the most flexible systems are the ones where new capability is expressed in the system rather than added to its code.

7.4 Additive Design, Still Fully Typed

There are two ways such a system grows, and both are additive — you extend by writing new code, never by editing old code.

The first is new compositions, which we have already seen: assemble existing primitives and combinators into new expressions. The second is new primitives: because Spec[T] is a deferred interface and pointedly not sealed, anyone can add a new atom — a First_Time_Buyer spec, a Contains_Restricted_Item spec — simply by writing another class that implements holds. The existing combinators combine it immediately, because they were written against the interface, not against any particular primitive. No enumeration to update, no match to extend; the new primitive snaps into the same sockets as the old ones.

This is the moment to notice a deliberate contrast with Part II. There, sealing a type was a virtue: a closed set of Order states let the compiler prove exhaustive handling. Here, leaving the Spec interface open is the virtue: an open set of primitives lets the vocabulary grow without edits. The difference is not a contradiction — it is the book's whole thesis showing its hand. You seal what must be reasoned about completely (the core's states); you leave open what must grow (the edge's vocabulary). Combinators are the first place we choose openness on purpose — and, crucially, we pay nothing for it in type safety. Every spec, primitive or compound, is a fully-typed Spec[T]; the compiler still checks every use. This is why combinators are the bridge: they are additive and open like the flexibility tradition, yet closed and checked like the constraint tradition.

7.5 What the Types Still Don't Say

Closure guarantees that combinations type-check. It does not guarantee that they mean anything sensible. A combinator algebra can be well-typed and still assembled into nonsense — a discount combinator nested so that it produces a negative price, a retry combinator composed to loop forever. The type Spec[T] promises a boolean answer; it does not promise the answer is reasonable, and for richer combinator types the gap is wider.

This is the familiar handoff. Where a combinator carries an obligation the type cannot express — a pricing combinator that must never drive a total below zero, a schedule combinator whose windows must not overlap — the obligation belongs in a contract on the interface: a postcondition every combining form must preserve, so that composing well-behaved parts yields a well-behaved whole. We will need this in earnest in Part IV, when the combinators produce prices and the results flow back to the constrained core across the membrane. For a predicate algebra the risk is mild; for a value-producing one it is the crux, and contracts are what keep an open algebra correct.

7.6 The Bridge

Combinators are the hinge of the book made concrete. They belong to the flexibility tradition — additive, open to new primitives, capable of expressing behaviors the author never enumerated — and yet they never leave the type system. Nothing here is Any-typed, nothing is dispatched at runtime on an unknown shape; every composition is checked. That is exactly why Part III comes before Part IV. The reader should first taste openness in its safe form — open vocabulary, closed types — before we step outside the type system in earnest and ask contracts to do the guarding the compiler no longer can.

One combinator move remains before we cross that line, and it is the natural sequel to this one. Combinators compose parts side by side, at the same level. But often you want to add capability on top of existing behavior — to wrap a base with a new stratum that adds something without disturbing what it wraps. That is layering, and it is the next chapter: behavior in strata, still fully typed, and the last step of the bridge before the frontier.