Skip to content

Exceptions & cleanup

E# splits failure into two complementary surfaces. Result<T, E> and ? are the value-based half — expected, recoverable failure modeled as a value. This page is the other half: exceptions (try / catch / throw), the CLR’s mechanism for exceptional conditions, and defer, the single scope-cleanup construct that runs however a scope is left. The two halves meet at the catch-and-translate boundary, where a thrown exception becomes a Result and flow returns to the value-based world.

The guideline throughout is fit, not prohibition: model expected failure as a Result, let exceptional conditions throw, convert between them where they meet, and use defer for cleanup that must happen regardless of which way control leaves.

try Block CatchClause { CatchClause } runs the try block and, if it throws, dispatches to the first matching catch. There is no finally clause — cleanup is defer, below.

The catch binding is name-first (name: Type), like every other declaration in the language — never the type-first catch (Exception e) of C#/Java. Four shapes cover the space:

try {
risky()
}
catch (e: FormatException) { log(e.Message) } // typed + bound
catch (_: IOException) { log("io failed") } // typed, value discarded
catch (e) { log(e.Message) } // bound, any exception
catch { log("failed") } // bare — any exception, no binding

catch (e) and bare catch both catch any System.Exception; the difference is only whether the value is bound. catch (_: T) catches a T but discards the value — the discard reads the intent (“I expect this type, I don’t need the object”) without an unused binding.

Clauses are tried top to bottom, and the first whose type matches wins — so order from most specific to most general. A typed clause placed after a catch-all that already matches everything is dead:

try { parse(s) }
catch (e: FormatException) { return error(.malformed) } // specific first
catch (e: ArgumentException) { return error(.badArg) }
catch (e) { return error(.unknown) } // general last

throw e raises an exception. A bare throw with no operand rethrows the exception currently being handled and is valid only inside a catch — it preserves the original stack trace, where throw e on the caught value would reset it:

catch (e: SqlException) {
metrics.count("db.error")
throw // rethrow, original stack intact — only legal in a catch
}

Because the BCL throws and E# code idiomatically passes Result, the most common try is a thin seam that catches at the throwing call and translates into a Result, returning to value-based flow:

func parsePort(s: string) -> Result<int, string> {
try {
return ok(int.Parse(s))
} catch (e: FormatException) {
return error("bad port: {e.Message}")
}
}

From the caller’s side this function never throws for a bad input — the failure is a value, propagated with ? like any other. This is the conversion that keeps the two halves of the error model from being separate worlds: exceptions are caught where they arise, expected failure flows as a Result everywhere else.

defer { … } registers its block to run on scope exit, and is E#‘s only cleanup mechanism — there is no finally keyword. It runs on every way the scope is left:

  • normal completion (falling off the end of the scope),
  • an early return,
  • a break out of the enclosing loop,
  • an exception unwinding through the scope.

Multiple defers in one scope run in LIFO order — the last one reached runs first — so cleanup unwinds in the reverse of the order resources were acquired:

func copy(src: string, dst: string) -> Result<int, string> {
let inp = open(src)?
defer { inp.close() } // runs second
let outp = create(dst)?
defer { outp.close() } // runs first (LIFO)
return ok(pump(inp, outp)) // both closes run on the way out, outp then inp
}

A defer runs after the value of a return expression is computed, so it cannot change what is returned; it observes the exit, it does not intercept it.

defer { D } compiles the remainder of its block into a CIL .try region whose .finally handler holds D, so D runs on whatever path leaves the region. Stacking defers nests these handler regions, which is what produces LIFO order. The .finally handler is a metadata construct the compiler emits in IL — it is never a surface form, and there is no finally keyword in E#.

A cleanup body that awaits cannot stay a handler: the CLR forbids branching into one, and an async function resumes by branching to the resume point of the await it suspended at. Such a region instead runs its protected body under a recording catch-all, then executes the cleanup as ordinary code once the region has closed, and finally replays whatever outcome was recorded — a propagating fault (re-raised with its original stack trace), or a return / break / continue deferred from inside the body. The observable semantics are unchanged: cleanup still runs on every exit path, in LIFO order, and a returned value is still evaluated before the cleanup runs.

Inside an async function a defer body runs exactly once, on real scope exit — it does not run when the function merely suspends at an await and returns control to its caller. An await partway through a defer-protected region suspends and resumes any number of times before the scope is actually left; the cleanup waits for the true exit:

func session() -> int {
let conn = connect()
defer { conn.close() } // runs once, when session() truly returns —
let a = await conn.fetch(1) // not on this suspension,
let b = await conn.fetch(2) // nor this one
return a + b // close() runs here, after the value is computed
}

This holds for every exit out of the async scope — normal completion, return, break, and an exception unwinding — each runs the defer once, and a suspension runs it zero times. (Mechanically, the emitted .finally handler is reached by the suspend as well as by real exits, so the state machine guards it: it runs the cleanup only when leaving for good, and skips it while suspended. This is invisible at the language level — the rule is simply “once, on real exit.”)

defer is therefore the way to release an async-held resource — including draining and disposing an async stream. The await for consumer disposes its enumerator through exactly this mechanism, so the enumerator is disposed on every exit, an early return from the loop body included:

func firstEven(src: IAsyncEnumerable<int>) -> int {
await for v in src {
if v % 2 == 0 { return v } // early return — the enumerator is still disposed
}
return -1 // full drain — also disposed
}

A cleanup body may itself await. Asynchronous release is therefore written directly, with no blocking bridge:

let conn = await Db.connect(url)
defer { await conn.DisposeAsync() }

The enclosing function is asynchronous whenever a defer body awaits, even if its main body contains no await of its own — the cleanup is part of the function’s own execution, not a detached task.

The same holds for a catch body: a handler may await, and the fault it does not handle still propagates afterwards with its original stack trace intact.

defer is not coupled to exceptions — it is E#‘s general run-on-scope-exit primitive, the construct a growing set of modern languages converge on (Go’s defer, Swift’s defer, Zig’s defer, D’s scope(exit)). E# takes the unified position: there is one cleanup construct and it subsumes the roles other CLR languages split across using and try/finally. There is no using statement and no finally keyword — using "System" is an import, nothing more. Acquire a resource, declare its release on the next line, and read the two together:

let lease = pool.acquire()
defer { lease.release() }
// ... use lease; release() runs however this scope is left

Most cleanup needs no try at all. The value is keeping acquisition and release adjacent and symmetric — the release is written once, next to the acquire, and the compiler guarantees it runs on every path out, so there is no way to add an early return later that leaks the resource.

The construct shines when one function owns a resource for its whole scope while handing it to a mix of sync and async helpers, and a single exit obligation has to fire no matter how the function returns — an arena allocator that must be flushed when its owning operation ends, for instance:

func render() -> int {
let arena = Arena()
defer { arena.flush() } // flush ONCE, on whichever path render() returns
buildHeader(arena) // a sync helper writes into the arena
let body = await loadBody(arena) // an async helper awaits, then writes into it
if body > limit { return -1 } // early bail — arena still flushed
return body // flush runs here, after the value is computed
}

render owns the arena; buildHeader and loadBody borrow it. The await in the middle suspends and resumes without ever triggering the flush (above) — the flush is bound to render’s return, not to any suspension or to the helpers’ own scopes. The single defer replaces what would otherwise be a flush call duplicated on the normal return, the early return, and any error path.

Composing with IDisposable and IAsyncDisposable

Section titled “Composing with IDisposable and IAsyncDisposable”

A class that implements IDisposable is released by a defer over its Dispose() — this is the direct replacement for a C# using block, with no extra keyword and the same scope-bound lifetime:

let f = File.OpenRead(path)
defer { f.Dispose() } // the `using`-equivalent — released on scope exit
return ok(f.ReadByte())

An IAsyncDisposable composes the same way — the cleanup body simply awaits:

let conn = await Db.connect(url)
defer { await conn.DisposeAsync() }
let rows = await conn.query("select 1")
return ok(rows)

Both compose under LIFO, so a resource opened later is released first — exactly the order nested usings would give, but flat and without the rightward drift:

func work() -> int {
let f = FileHandle()
defer { f.Dispose() } // released second
let c = Conn()
defer { c.DisposeAsync().AsTask().GetAwaiter().GetResult() }// released first (LIFO)
let n = await c.fetch()
return n + 1 // c disposed, then f
}

This is the same machinery await for uses internally to dispose its async enumerator — the consumer surface and user code share one cleanup primitive.

There is no finally; a defer placed inside a try block is the equivalent — it runs when the try scope is left, before control reaches a matching catch for an exception unwinding through it:

try {
let lease = acquire()
defer { lease.release() } // runs as the exception unwinds, before the catch below
use(lease) // throws
}
catch (e) {
return error(.leaseFailed) // lease already released by the defer
}
  • Errors: Result & ? — the value-based half of the error model, and the catch-and-translate boundary from the other direction.
  • Statements — the grammar for try / catch / throw, defer, and raise.
  • Concurrencyasync / await, await for, and async streams.
  • Diagnostics — the diagnostics raised for ill-formed catch ordering, a bare throw outside a catch, and related checks.