Go's defer, and Why Koru Writes It For You

· 6 min read

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 — yield and sum(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 recognizes defer — and then finds they never had to write it.
  • Language corner. The generator entries lean on ! each, subflows, and consumer-side capture. 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.