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 / catch
Section titled “try / catch”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 + boundcatch (_: IOException) { log("io failed") } // typed, value discardedcatch (e) { log(e.Message) } // bound, any exceptioncatch { log("failed") } // bare — any exception, no bindingcatch (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.
Multiple clauses dispatch first-match
Section titled “Multiple clauses dispatch first-match”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 firstcatch (e: ArgumentException) { return error(.badArg) }catch (e) { return error(.unknown) } // general lastthrow and rethrow
Section titled “throw and rethrow”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}The catch-and-translate boundary
Section titled “The catch-and-translate boundary”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
breakout 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.
Lowering
Section titled “Lowering”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.
defer under async
Section titled “defer under async”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.”)
Async resource cleanup
Section titled “Async resource cleanup”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 as the scope-exit primitive
Section titled “defer as the scope-exit primitive”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 leftMost 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 exitreturn 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.
Interaction with try
Section titled “Interaction with try”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}See also
Section titled “See also”- 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, andraise. - Concurrency —
async/await,await for, and async streams. - Diagnostics — the diagnostics raised for ill-formed
catchordering, a barethrowoutside acatch, and related checks.