Go's defer, and Why Koru Writes It For You
Coming from Go, defer is the cleanup idiom you reach for without thinking: take a
lock, and on the very next line schedule its release, so it runs when the function
returns — on the happy path, on an early return, on a panic. It reads
top-to-bottom and keeps acquisition and release side by side. Koru says the same
thing, but it says it in the type — and once the lifecycle is in the type, you
stop writing the release at all. The compiler writes it for you.
Coming from Go
Here is the idiom you already know — the canonical use of defer:
mu.Lock()
defer mu.Unlock()
// ... critical section ... defer mu.Unlock() is a promise: whatever happens next, the unlock runs at scope
exit. It is a lovely idiom — and it has exactly one soft spot: it is a convention.
Forget the line and the program still compiles, still runs, and deadlocks the next
goroutine that wants the lock. The compiler has nothing to say.
Koru’s native word for it
Koru’s thesis is control flow into the type system, so “this must be released” is not a runtime discipline — it is a fact carried on the value’s type. Taking the lock mints an obligation:
pub event lock { name: []const u8 } -> []const u8<held!>
lock -> name The return type is []const u8<held!>. The <held!> is a phantom state — a marker
the compiler tracks and nothing runtime can see. It says “this value carries an
unmet obligation.” The only way to meet it is an event that consumes <!held>:
pub event unlock { name: []const u8<!held> }
unlock = std/io:print.ln("unlock {{ name:s }}") unlock is the whole of Koru’s defer. And here is the turn: you do not have to
call it. If a <held!> value reaches scope exit undischarged, and exactly one
event can retire it, the compiler inserts that call for you. Where Go asks you to
write defer mu.Unlock(), Koru writes it. You cannot forget the release, because
you never wrote it — and you cannot leak the lock, because the binary that leaks it
does not compile.
The port, running
Take two locks, do the work, and write no unlock at all:
lock(name: "outer"): outer |> lock(name: "inner"): inner
|> std/io:print.ln("critical section") Two obligations are live; neither is discharged in the source. The compiler inserts both releases at scope exit, and it unwinds them LIFO — inner released before outer:
critical section
unlock inner
unlock outer That LIFO order is not cosmetic and it is not luck. An inner resource is often
acquired because the outer one is live — a transaction under a connection, a guard
under a lock — so releasing the outer first would pull the ground out from under the
inner. Reverse-acquisition order is the only safe default, which is exactly why Go’s
stacked defers, C++ destructors, and Rust’s drop all unwind that way. Koru’s
auto-discharge now does too: the compiler collects the live obligations in
acquisition order and inserts their releases in reverse. (That ordering landed as a
one-commit fix while this post was being written — before it, the inserted releases followed
an internal hash order, which was fine for independent locks and wrong the moment
resources depended on each other.)
And LIFO is the default, not a straitjacket. Auto-discharge only fills in what you
leave undischarged, so you steer the order by hand whenever you need to: release the
outer lock explicitly, before the inner, and the compiler inserts only the inner’s
release afterward. The order becomes yours — outer, then inner — with
auto-discharge sweeping up whatever you didn’t name, and never double-freeing what
you did. Go’s stacked defers only ever unwind one way; here the default is safe and
the override is a single explicit call.
A mutex, incidentally, is a symmetric resource: its own lifecycle is just
held → released, with the real work happening on whatever state the lock guards,
which is orthogonal to the token. So lock/unlock is the honest model — there is
no invented middle state to thread. (Resources whose own lifecycle has meaningful
stages — open → active → closed — are a different, asymmetric story, and one Koru
tells in a way defer structurally cannot. That’s a post of its own.)
The teeth Go doesn’t have
If you can’t forget the unlock, where are the teeth? Not in forgetting — in repeating. Release a lock, then release it again:
lock(name: "m"): m |> unlock(name: m) |> unlock(name: m) In Go and C, double-unlock is undefined behaviour — a runtime panic if you’re lucky,
silent corruption if you’re not, and defer does nothing to stop it. In Koru the
first unlock spends the obligation, so the token’s type no longer carries <held!> to hand on. The second unlock has nothing to consume, and the phantom checker
rejects the reuse at compile time:
error[KORU030]: Use-after-discharge: binding 'm' was already discharged
and cannot be used Use-after-release is not merely unlucky here — it is unrepresentable. It is pinned
as a MUST_FAIL twin, so the wall is a test that stays standing, not a claim in
prose:
The gaps this fills
Two axes, named plainly, because variance is the point of the catalog.
- Source / idiom. The seed catalog is Python generators —
yieldandsum(gen). This is the first Go entry, and the first to port a cleanup idiom rather than a production one. A Gopher reads the doorway and recognizesdefer— and then finds they never had to write it. - Language corner. The generator entries lean on
! each, subflows, and consumer-sidecapture. This one lights up the corner they leave dark: phantom obligations —<held!>minted at acquisition,<!held>as the sole discharge, auto-discharge inserting the release, and use-after-discharge caught at compile time. No effect stream, no fold; a resource lifecycle expressed as a type.
Where it stuck
Nowhere — the translation held, and it held harder than the doorway promised. Go’s defer is a release you remember to schedule; Koru’s answer is a release you can’t
schedule wrong, can’t forget, and can’t repeat. You don’t write defer mu.Unlock().
You write the lock, do the work, and let the type close the loop.