Passing Side Effects as Values
Memory answered where values live; effects answer what a piece of code is allowed to do — touch the database, read the clock, send mail. Mere's answer is capability passing: the right to cause an effect is an ordinary value you hand in as an argument, so a function's parameter list is the exhaustive list of what it can do. And the notorious cost — plumbing capabilities through functions that don't use them — is accepted on purpose.
Part II gave Mere an explicit answer to where values live and when they die. Part III takes on a question the language is just as unwilling to leave implicit: what is a piece of code allowed to do? Can this function reach the network, read the clock, open a file, send an email? In most languages the honest answer is “no way to tell without reading the whole call tree,” because any function can import any global and do anything. That is exactly the kind of invisible power Mere’s first principle is against. This part is about making it visible.
Four ways to make effects explicit
There is a well-mapped design space here, and it is worth seeing the options because the choice follows directly from what the language already values.
- Monads, as in Haskell — thread effects through the type of the return value. Explicit and pure, but they compose awkwardly and cost real syntax.
- Algebraic effects, as in Koka — declare effects a function performs and handle them elsewhere. They compose beautifully and test well, but the runtime machinery to suspend and resume computations is not free.
- Capability passing — the right to cause an effect is a value, passed in as an argument. Maximally explicit, zero runtime cost, easy to test, at the price of verbosity.
- Annotations only, like checked exceptions — just declare “this function does IO.” Cheap to write, but weak: the annotation drifts from the truth and the compiler can’t really hold you to it.
Line those up against what Mere has already committed to — explicit over implicit, zero runtime cost, and verbosity treated as affordable because the code is written with machine help — and one of them fits without compromise.
Capability passing
Mere’s answer is capability passing: the authority to perform an effect is an ordinary value, and you get it the ordinary way — someone hands it to you as an argument.
fn save_order(
order: owned Order,
db: &mut borrowed Database,
logger: &borrowed Logger,
) -> Result[unit, owned DbError] {
logger.info("saving order")
db.insert("orders", order)
}
Read the parameter list and you have read the function’s entire reach. It can
talk to a database and write to a logger, because it was given a Database and a
Logger, and it can do nothing else to the outside world, because there is no
other way to get that authority — there are no ambient globals to import, no
hidden channel to the operating system. What a function may do is not documented
alongside the code; it is the code’s signature. That is the whole idea:
side effects don’t travel for free, they travel as values you can see being
handed over.
This also makes effects testable for free, with no special framework. A test
passes a fake Database and a Logger that writes to a buffer, and the function
under test cannot tell the difference or escape to the real ones — because it only
ever had the values it was given.
The plumbing problem, and why Mere keeps the plumbing
The standard objection to capability passing is immediate and real. In a deep call chain, a middle function has to accept capabilities it never touches itself, purely to pass them further down:
fn handle_request(req, db, cache, logger, mailer) -> Response {
let user = authenticate(req, db, logger)?
let order = process_order(user, db, cache, logger, mailer)?
build_response(order)
}
// process_order never sends mail itself — it takes `mailer` only to hand
// it to send_confirmation further down
Most languages treat this plumbing as a problem to be engineered away, usually by letting the deep function reach a global directly so the middle functions don’t have to know. Mere does the opposite: it accepts the plumbing on purpose, for four reasons that are really one reason seen from four sides.
The verbosity itself doesn’t count against it — this is code written with machine
help, and threading a new capability through a chain is exactly the kind of
mechanical edit that is cheap to make and to verify. More importantly, the
plumbing is information: a function that has mailer in its parameter list is,
visibly, on the path by which mail gets sent, even if it only passes the value
along. And when a function ends up demanding a long list of capabilities, that is
not noise to suppress — it is a design signal, telling you the function sits on
too many effect paths and the code may want restructuring. The plumbing is the
diagnostic; hiding it hides the diagnosis.
There is a sharp distinction underneath this that the design notes insist on: “don’t make carrying easy” is not the same as “don’t bundle.” Mere refuses the first — it will not add a mechanism whose purpose is to make effects travel invisibly, because invisible travel is precisely the thing the whole part exists to prevent. A “capabilities struct” that magically threads everything through would re-hide exactly what capability passing is trying to show. But that is a different question from whether related capabilities can be grouped under a name for convenience without making them invisible — and that question is left open here, to be answered later in the part.
What this deliberately leaves unfinished
Committing to capability passing settles the direction but not the whole design, and it is worth being honest about the seams, because the next few episodes are about exactly these:
- Higher-order functions. If
map(list, f)callsf, andfneeds a capability, where does that requirement show up? It can’t silently leak intomap’s signature, or no generic function could ever be written. This is the first real strain on the model, and the next episode is about resolving it. - The kind of effect. A parameter tells you a function can touch the database, but not whether it only reads or also writes, nor whether an effect is exclusive or safely shared — a logger is written to, yet many callers can log at once. Presence of a parameter is too coarse; the effects need a grain.
- Bundling without hiding. If a realistic function needs five capabilities plus a region parameter, its signature gets long. Some way to group is probably needed — the open question is what shape it takes without crossing back into “carrying made easy.”
The direction is fixed: effects are values, passed explicitly, plumbing and all. The rest of Part III is about the three strains above. Next: higher-order functions and capabilities — how a function that takes another function keeps effects explicit without leaking them.