Everything in Parts I through III stayed inside the type system. Even the flexible parts like combinators and layers were flexible only in ways the compiler could still check, because every new piece had to fit an interface declared in advance. But some growth cannot be foreseen well enough to name an interface up front, and for that growth the type system is not a help but an obstacle.
Part IV is where we open the frontier. We do it carefully, and we do it knowing exactly what we are giving up — because we have now spent six chapters seeing what safety looks like, so stepping outside it reads as a considered trade, not a default. The first move is the foundational one: generic operations, an operation whose behavior for a given kind of argument is looked up at runtime and can be extended by anyone, without editing the operation or the argument's type.
9.1 The Growth You Cannot Foresee
The running system has been waiting for this. Its pricing engine is supposed to be the open edge — "the rules that decide what an order costs will change forever, and they must be addable without reopening the core." So far we have deferred the how. Now we shall confront it.
The pricing engine must support an open-ended set of rule kinds — percentage discounts, loyalty tiers, bundle deals, tax by jurisdiction, whatever marketing invents next quarter — and for each, compute a price. Try to build this with the tools of Part III and you hit a wall from two sides at once. If the engine matches over a sealed set of rule kinds, then every new rule means editing the engine and its exhaustive match — the sealed core's virtue is now a cage. If instead each rule carries its own price method, then teaching the system a new operation over all rules — say, "explain this rule to the customer" — means editing every rule class.
This is the classic problem: you want to add new kinds of data and new operations over them independently, without either forcing edits to the other. No fixed interface can solve this, because both the set of kinds and the set of operations are meant to stay open. What you need is a way to associate an operation with a kind from the outside — a table that anyone can extend.
9.2 Dispatch Through a Table
A generic operation keeps its implementations in a registry, keyed by the kind of thing it is operating on, and dispatches at runtime by looking up the key. The operation itself knows nothing about any particular kind; it knows only how to find the handler and call it. Adding a kind is adding a row to the table.
Here is the pricing engine built that way. A Rule carries a string tag naming its kind; the engine holds a map from tag to handler; pricing is a lookup followed by a call. Each handler names the kind it prices, and the public price routine, written once on Pricing_Handler, refuses any other kind before handing the work to the handler's own compute.
-- A rule knows its own kind but not how it is priced.
deferred class Rule
feature kind(): String deferred
end
-- A handler knows how to price one kind of rule.
deferred class Pricing_Handler
feature
kind(): String deferred -- the rule kind this handler prices
-- What every caller sees: the terms are stated once, here, for all handlers.
price(rule: Rule, order: Placed): Money
require
matches: rule.kind = kind
do
result := compute(rule, order)
end
compute(rule: Rule, order: Placed): Money deferred -- each handler provides this
end
class Pricing_Engine
create make() do handlers := {} end -- an empty registry
feature
handlers: Map[String, Pricing_Handler]
register(kind: String, h: Pricing_Handler)
do
handlers.put(kind, h)
end
price(rule: Rule, order: Placed): Money
require
known_rule: handlers.contains_key(rule.kind) -- the type system can't promise this
do
result := handlers.get(rule.kind).price(rule, order)
ensure
non_negative: not result.is_negative() -- so a contract does
same_currency: result.currency = order.total.currency
not_below_floor: result >= order.floor_price
end
end
The engine is now closed to modification and open to extension in the strongest sense we have seen. It has no branch per rule and no knowledge of any specific rule at all. To teach it a new rule kind, you never touch this class.
9.3 Adding a Rule, Editing Nothing
A rule kind is a Rule subclass plus a Pricing_Handler for it, plus one register call. Watch where the type system goes quiet:
class Percent_Off_Rule inherit Rule
create make(p: Integer) do percent := p end
feature
percent: Integer
kind(): String do result := "percent_off" end
end
class Percent_Off_Handler inherit Pricing_Handler
feature
kind(): String do result := "percent_off" end
compute(rule: Rule, order: Placed): Money
do
if convert rule to r: Percent_Off_Rule then -- recover the concrete type at runtime
result := order.total.less_percent(r.percent)
else
raise "handler registered under 'percent_off' received a different rule"
end
end
end
Registering it wires the kind to its handler — and a second, entirely new rule slots in the same way, with zero edits to Pricing_Engine or to any existing rule:
engine.register("percent_off", create Percent_Off_Handler)
-- Later, a rule nobody anticipated — added by extension, not surgery:
engine.register("loyalty_tier", create Loyalty_Handler.make())
let quoted: Money := engine.price(rule, order)
The set of pricing rules is now genuinely open: a new rule is new code in a new file, and nothing that already exists is touched. Both axes of growth — new kinds, and new operations if we add more tables — are additive. This is the expression problem, and the table sidesteps it rather than solving it. As Wadler posed the problem, it asks for both axes of growth without giving up static type safety. The table gets both axes by keeping the association between kind and behavior in data, where anyone can add to it, and pays for that with static safety at the one point where a kind meets its handler. That is the trade the next section makes explicit.
9.4 Where the Types Fall Silent
We paid for this, and it is important to be exact about the coin. Three things the compiler used to guarantee, it can no longer see.
That a handler exists. handlers.get(rule.kind) looks up a string key. Nothing at compile time promises the table has a row for this rule; a rule whose kind was never registered sails past the type checker and fails only at runtime.
That the handler matches the rule. The line convert rule to r: Percent_Off_Rule is a downcast from the open Rule type to a specific one, checked only at runtime. The compiler cannot verify that the handler registered under "percent_off" actually receives a Percent_Off_Rule — the correspondence between the string tag and the class is invisible to it.
That the produced price is well-formed. Because the handler is called through a table, the compiler cannot follow the value it returns; a buggy handler could return a negative price, one in the wrong currency, or one below the order's floor, and no type would object.
This is not a language deficiency to route around; it is the deal. We deliberately traded a closed, statically-checked set of kinds for an open, runtime-dispatched one, and the loss of these three guarantees is the exact price of the openness. The question is not how to avoid paying it — you cannot, if you want the openness — but how to buy back the guarantees you most need. And the tool for that is the one we have been holding in reserve since Chapter 2.
9.5 Contracts Take Over the Guarding
Look again at the code, now for its contracts rather than its dispatch. Every place the type system fell silent, a contract speaks in its place — and the three losses of §9.4 map one-to-one onto three guards.
The engine's precondition known_rule: handlers.contains_key(rule.kind) stands exactly where the compiler could no longer promise a handler exists. It turns "there is no handler" from a mysterious runtime lookup failure into a named, located contract violation at the boundary — the place a missing registration should be caught. On every handler, the precondition matches: rule.kind = kind states the handler's terms: a rule registered under the wrong kind is refused at the door, by name. It is written once, on Pricing_Handler, rather than on each handler, so a caller holding any Pricing_Handler can see it, and no handler can demand more than the interface promised its callers (§8.5). It cannot make the downcast safe on its own, because a string tag does not prove a class (§9.4); a rule could claim the kind "percent_off" without being a Percent_Off_Rule. That is why the convert keeps its else branch, which turns a mismatched class into a named failure instead of a wrong price. And the engine's postconditions — non_negative, same_currency, and not_below_floor — guard the value on its way out, so that whichever of the open-ended handlers produced the price, and whatever bug it harbors, a malformed price cannot leave the engine.
Those postconditions are not new. They are the membrane from Chapter 2, finally doing its real work. Recall apply_quote, the core's boundary routine, whose precondition stated the terms on which the core would accept a price. The engine's postcondition is the matching promise on the other side of that boundary: the open pricing edge guarantees exactly what the constrained core requires. Neither side sees inside the other; the contract is the whole of their agreement. This is the arrangement the first chapter sketched — constrain the core, open the edge, let contracts be the membrane between them — running for real, with a genuinely open edge on one side and a genuinely closed core on the other.
9.6 A Considered Trade
It would be a misreading of this chapter to conclude that generic operations are simply better than the typed composition of Part III. They cost more, and buy something the typed composition of Part III cannot. You give up compile-time proof that every case is handled, every value is well-formed, and a refactor did not orphan a handler. In exchange you gain extension by strangers — the ability for a rule written next year, by someone who has never seen the engine, to slot in without a single edit to existing code. That trade is worth making exactly when the set of kinds is genuinely open and genuinely unpredictable, and worth refusing when it is not. A payment status that will only ever be one of four values should stay a sealed type; a pricing rule catalog that grows with the business wants a table. Knowing which is which is the judgment we should strive to make.
The essential point is that the openness is bounded. We did not abandon guarantees; we relocated them from the type system to contracts, and concentrated them at the membrane where the open edge meets the closed core. The frontier is open, but it is fenced.
Generic operations are the foundation of everything left in Part IV. Once an operation's behaviors live in a table that grows at runtime, you are a short step from letting the vocabulary itself grow — from giving the domain its own language whose terms can be added on the fly (Chapter 10), and then from building evaluators whose primitives extend at runtime (Chapter 11). Each is a wider opening of the same door, and each leans harder on contracts to stay reliable. We have crossed the line; now we build on the far side of it.