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`.

mereeffectscapabilitieslifecyclelanguage-design

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 with at 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 with over a child region, for a request’s own capabilities and its scratch memory, all released together when the request ends.
  • function-scoped — a local with for 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.

← Back to Mere: Building a Language