Let It Leak Purity
Stop hating on functions. They can't help what color they are; they were born that way. You ought to know. You made them.
We all hate leaky abstractions. But if an abstraction is going to leak, I say let it leak purity.
That's not what async/await leaks. It's not what try/catch leaks. Those aren't abstractions, they're infections. Mark one function async and every caller has to turn async too. Throw from one place and every layer above it has to wrap itself in try. They spread through your code whether you want them or not, and what spreads carries no information: "something down there is slow," "something down there might blow up." Try/catch is the poisoned tree, and async/await is its deadly fruit. Neither deserves to see the light of day in a reasonable language.
Errors are enum variants. A function that can fail says so in its signature, and the caller handles every way it can fail, by name, or it doesn't compile.
Asynchronous code doesn't exist. Let that sit for a moment. Async is in your mind. There's no such thing as asking for data that doesn't exist yet. You ask for data, you get data. Never mind when; you get it before you need it, and the compiler handles the waiting.
No try. Only do.
That's the theory. Here's what it does to real code.
The code you actually write first
Nobody starts with a clean architecture. You start with the thing that works:
function checkout(lines: Line[]) {
if (lines.length == 0) {
@msg = "cart is empty"
return
}
let t = 0
for (const l of lines) { t = t + l.qty * l.cents }
t = t + Math.round(t * 0.08)
if (t > 100000) {
@msg = "over limit: " + t
return
}
?{`INSERT INTO orders (cents, at) VALUES (${t}, ${now()})`}.run()
@msg = "charged " + t
}
<button onclick=checkout(@cart)>Buy</button>
<p>${@msg}</p>
That compiles. It runs. scrml doesn't punish you for prototyping, and it shouldn't.
But look at what's knotted together in there. The arithmetic (line totals, tax). The business rules (an empty cart, a spending limit). The effects (a database write, a clock read). And the UI, because the only way this function can tell anyone it failed is by writing a sentence into a display cell. Nothing that calls checkout can know it failed. The failure is a string on a screen.
In most languages, that's where the code stays, because nothing pushes back.
Give the failure a name
Say the cart rules matter, and you'd like the caller to actually know when checkout didn't happen. So you give the failures names:
type CartError:enum = { Empty, OverLimit(cents: int) }
function checkout(lines: Line[])! -> CartError {
if (lines.length == 0) { fail CartError::Empty }
let t = 0
for (const l of lines) { t = t + l.qty * l.cents }
t = t + Math.round(t * 0.08)
if (t > 100000) { fail CartError::OverLimit(t) }
?{`INSERT INTO orders (cents, at) VALUES (${t}, ${now()})`}.run()
return t
}
function buy() {
checkout(@cart)
}
The @msg writes are gone from checkout. It doesn't know there's a screen anymore; it says what happened and returns. And the very next thing the compiler does is refuse buy:
error [E-ERROR-002]: Result of failable function 'checkout' is not handled.
Either match the result, propagate with '?', catch with '!{}', or wrap in '<errorBoundary>'.
So the caller handles it, by name:
function buy() {
const t = checkout(@cart) !{
.Empty :> { @msg = "cart is empty"; return }
}
@msg = "charged " + t
}
Not good enough either:
error [E-TYPE-080]: Non-exhaustive error handler for `CartError`. Missing variant(s): OverLimit.
Every way it can fail, by name, or it doesn't compile. And notice where the UI text ended up: in buy, the function that owns the screen, not in checkout, the function that owns the order. You didn't decide to separate those. The error system moved them for you.
That's the first leak. It isn't an infection. It stops wherever you handle it, and you choose where that is.
Give the arithmetic a name
Now the math. You want to know that the total is right: same cart, same number, every time. So you try to say so:
fn total(lines: Line[]) -> int { ... }
fn is the pure color: same input, same answer, touches nothing. And here's the part that matters: once a function is pure, everything it calls has to be pure too, and the compiler holds that line. Leave any of the prototype's habits inside it and it's refused:
Inside an fn | Compiler says |
return now() | E-FN-004: now() is non-deterministic; it reads host state |
return checkout(lines) | E-FN-003: checkout is a function, not an fn |
?{ SELECT … } | E-FN-001: a pure function may not touch the database |
@cart = [] | E-FN-003: a pure function may not write state |
The only way to make total pure is to pull out everything that isn't. The clock goes. The database goes. The state writes go. What's left is arithmetic, and the arithmetic splits along the same line, because total can only call other fns:
fn lineTotal(l: Line) -> int { return l.qty * l.cents }
fn subtotal(lines: Line[]) -> int { return lines.reduce((a, l) => a + lineTotal(l), 0) }
fn tax(cents: int) -> int { return round(cents * 0.08) }
fn total(lines: Line[]) -> int { return subtotal(lines) + tax(subtotal(lines)) }
That's the second leak, and it flows the right way: down. The purity of total forced the purity of everything under it.
What leaked
Put it back together:
type CartError:enum = { Empty, OverLimit(cents: int) }
// pure: same input, same answer, touches nothing
fn lineTotal(l: Line) -> int { return l.qty * l.cents }
fn subtotal(lines: Line[]) -> int { return lines.reduce((a, l) => a + lineTotal(l), 0) }
fn tax(cents: int) -> int { return round(cents * 0.08) }
fn total(lines: Line[]) -> int { return subtotal(lines) + tax(subtotal(lines)) }
// impure: writes the database, reads the clock, can fail, and says how
function checkout(lines: Line[])! -> CartError {
if (lines.length == 0) { fail CartError::Empty }
const t = total(lines)
if (t > 100000) { fail CartError::OverLimit(t) }
?{`INSERT INTO orders (cents, at) VALUES (${t}, ${now()})`}.run()
return t
}
// owns the screen: handles every failure by name
function buy() {
const t = checkout(@cart) !{
.Empty :> { @msg = "cart is empty"; return }
.OverLimit(c) :> { @msg = "over limit: " + c; return }
}
@msg = "charged " + t
}
Same behavior as the prototype. But now the signatures tell you the truth about the program. Which parts are calculation (fn). Which parts touch the world (function). What can go wrong, and every way it can (! -> CartError). Who owns the screen (whoever handles the variants). And there's still no async, no await, no try anywhere: checkout hits the database, buy calls it like any other function, and the compiler writes the waiting itself.
None of that structure came from discipline. You didn't sit down and design layers. You asked for two things, "tell me when checkout fails" and "promise me the math is the math," and the structure leaked in from the rules.
Async leaks slow up through your code. Try/catch leaks might explode. Purity and named failures leak shape.
That's a leak worth having.