Bundling Capabilities, and Where They Come From
Explicit capabilities plus fine-grained borrow modes make signatures long — nine parameters for a two-line function. The fix has to compress the notation without buying back the implicitness the whole part fought for. And a second question waits underneath: where do capabilities get built and destroyed? Both answers turn out to reuse machinery already in hand — one a pure type-level expansion, the other the memory model's own `with` and `region`.
Part III has made effects visible by passing capabilities as values, kept them visible through higher-order functions, and given them a grain with borrow modes. Each step added honesty, and honesty added length. This final effects episode is about paying that bill — the signatures have grown long — and about a question the part had left implicit all along: where do capabilities actually come from?
The thirteen-line signature
Put capability passing and fine-grained borrows together on a realistic function and the cost is stark:
fn save_order[region A](
order: owned Order,
db: &mut A Database,
cache: &borrowed Cache,
logger: &shared write Logger,
mailer: &borrowed Mailer,
metrics: &shared write Metrics,
clock: &borrowed Clock,
rng: &borrowed Random,
arena: &A Region,
) -> Result[owned OrderId, owned DbError]
where len(order.items) > 0
{
logger.info("saving order")
db.insert("orders", order)
}
Nine parameters, a where-clause, a return type — thirteen lines of signature over a two-line body. One such function is fine; a whole codebase of them is a review burden that swamps the code that actually does something. This is the ergonomic debt the first effects episode flagged, now come due.
The temptation is to hide the capabilities — a “context” object that quietly carries them all — but that is exactly the move Part III has refused, because it makes effects travel invisibly again. The distinction stated back in the first episode is the whole hinge here: “don’t make carrying easy” is not “don’t bundle.” Bundling is allowed. Bundling that hides is not. The job is to find a bundle that compresses the writing without erasing the information.
Signature aliases: compress the notation, not the information
Mere’s answer is a signature alias — a named group of parameters that expands in place:
signature server_caps =
db: &mut Database,
cache: &borrowed Cache,
logger: &shared write Logger,
mailer: &borrowed Mailer,
metrics: &shared write Metrics;
fn save_order[region A](order: owned Order, ...server_caps, arena: &A Region)
-> Result[owned OrderId, owned DbError]
{
logger.info("saving order")
db.insert("orders", order)
}
The ...server_caps spread drops all five parameters into the signature at that
point. What makes this acceptable where a hidden context object was rejected is
one property: it is referentially transparent. The spread is pure type-level
substitution — you can expand it back to the thirteen-line form mechanically, and
the two are the same function. The individual names survive: db, logger,
mailer are still there to use, not buried behind a ctx. The information
content is identical; only the notation is shorter.
That is the resolution of a tension the whole part carried. Part III accepted verbosity as the price of explicitness — but a signature alias shows the two were never truly the same thing. Explicit is not the same as verbose. You can keep every capability named and checked and still not write the list out at every site, as long as the shorthand expands to exactly the long form and hides nothing. A context object fails that test — it removes information — which is why it stayed rejected while the alias was adopted.
It isn’t free of edges, and the notes are honest about them: spreading two aliases
that both define a db is a name collision, resolved by making it a compile error
with an explicit rename to fix it; and a signature is a shared contract, so
changing one ripples through everything that spreads it. The alternatives
considered — a module-level implicit parameter à la Scala, and inference that
fills capabilities in silently inside a module — were both rejected for the same
reason: they buy brevity by reintroducing implicitness at exactly the boundaries
where the language most wants things spelled out.
Where capabilities come from — and a reunion with Part II
A signature says a function receives a Logger. It doesn’t say who built the
logger, or when it is torn down. A logger has to be constructed, a database
connection opened and eventually closed. Whose job is that, and where does it live?
Four options were on the table: build everything in main and pass it down
(classic dependency injection); construct capabilities as module-level globals
(rejected immediately — that is the ambient, invisible authority the whole part
exists to prevent); bind a capability to a region so it dies when the region does;
or construct and destroy it in an explicit scope. Mere took the last two as a
hybrid, with the first folded in as a special case — and the striking thing is
that it required no new machinery at all, because the constructs it needs were
already built in Part II.
A capability is constructed and released with with — the exact construct from
the memory-model episode on Trivial and cleanup. This is not a coincidence, it
is the two halves of the language meeting. Most capabilities are Drop: a
database closes its connection, a file releases its descriptor, a logger flushes.
That is precisely why they can’t live in a region, which holds only Trivial
values — and precisely why they live in with, which exists to manage cleanup in
LIFO order on every exit path. The effect system’s lifecycle problem and the
memory model’s cleanup problem are the same problem, and with was already the
answer.
That gives capabilities a natural hierarchy of lifetimes, expressed by nesting the same two constructs:
- application-wide — a
withat the program’s outermost scope, over a root region, for things that live as long as the process: a connection pool, the logger. - per-request — a nested
withover a child region, for a request’s own capabilities and its scratch memory, all released together when the request ends. - function-scoped — a local
withfor a capability needed only briefly.
Dependency injection — building everything in main — is just the outermost layer
of this, not a separate mechanism. And testing falls out for free, as it did in
the first effects episode: a test constructs fakes in the outer with and the code
under test cannot reach past what it was handed.
Part III, closed
The effect model is now complete, and its shape rhymes with the memory model’s.
Effects are values, passed explicitly; they stay visible through higher-order
functions because the capability is consumed before the function is generic; they
carry a grain — read or write, shared or exclusive — checked in the type and free
at runtime; they can be bundled without hiding, by an alias that expands to the
honest long form; and they are born and die in with, the same construct that
manages memory cleanup. Nothing here is ambient, nothing travels invisibly, and —
tellingly — the two hardest ergonomic answers reused machinery already in hand
rather than inventing more.
Two parts have now described what the language is: a memory model and an effect system, both explicit, both checkable, both leaning on a small set of concepts that keep reappearing. What they haven’t shown is how it runs. A language is a promise until something executes it — and Mere chose to execute it four different ways, and to hold them in exact agreement. Next, Part IV opens: why a language needs more than one backend, and what it means to keep them identical.