Advanced Control Flow
Beyond basic if/else, for, while, and match, Zolo provides several advanced control flow mechanisms.
Defer #
defer schedules an expression to run when the enclosing scope exits, regardless of how it exits (normal flow, early return, error):
fn process_file(path: str) {
let file = open(path)
defer file.close() // guaranteed to run when function exits
let data = file.read()
process(data)
// file.close() runs here automatically
}
Multiple Defers #
Multiple defer statements run in reverse order (LIFO):
fn multi_resource() {
let db = db_connect()
defer db.disconnect() // runs third
let cache = cache_open()
defer cache.close() // runs second
let lock = acquire_lock()
defer release_lock(lock) // runs first
// use resources...
}
Defer with Early Return #
fn validate_and_process(data: str?) {
let conn = open_connection()
defer conn.close() // always runs, even if we return early
if data == nil {
print("no data")
return // conn.close() still runs here
}
process(data!)
}
Defer on Panic #
defer also fires when the function exits via panic(...). The runtime
wraps the body in a per-function trap so the cleanup runs before the
panic propagates to the enclosing try / catch (or the top-level
panic handler):
fn boom() {
defer print("cleanup") // prints
panic("oops") // panic propagates AFTER cleanup
}
try { boom() } catch e { print("caught") }
// Output:
// cleanup
// caught
This applies to defers in any block scope — a defer inside an inner
{ ... } fires when a panic skips over its enclosing block, then the
function-level defers fire, then the panic re-emerges.
defer_ok and defer_err — Conditional Cleanup
The triad lets you split cleanup by exit path without juggling a boolean flag yourself. All three share one LIFO stack per block.
| Keyword | Fires when the block exits via… |
|---|---|
defer |
any exit — fall-through, return, ?, panic |
defer_ok |
success only — return Ok, fall-through, break |
defer_err |
failure only — return Err, ?, panic |
use std::Result
fn transfer(from: Account, to: Account, amount: Money) -> Result<(), Error> {
let tx = db.begin()?
defer_err tx.rollback() // only if something below fails
defer_ok tx.commit() // only on the success path
defer db.log_metrics() // always runs
debit(tx, from, amount)?
credit(tx, to, amount)?
Result.Ok(())
}
The compiler decides at run-time whether each registered cleanup fires,
based on the function's exit value. defer_ok / defer_err accept an
optional |binding| to capture the returned value:
fn handle(req: Request) -> Result<Response, ApiError> {
defer_ok |r: Response| metrics.success(req.trace, r.status)
defer_err |e: ApiError| metrics.failure(req.trace, e.code)
process(req)
}
Inside a Result<T, E> function the binding receives the inner T or
E, not the wrapping Result. For non-Result functions and panic
payloads the value flows through unchanged.
Restrictions in the Defer Body #
The compiler rejects (E_DEFER_003) control-flow constructs that
would jump out of the cleanup scope:
fn bad() {
defer return // E_DEFER_003: `return` not allowed in defer body
defer { foo()? } // E_DEFER_003: `?` not allowed in defer body
defer { break } // E_DEFER_003: `break` not allowed in defer body
}
Use a nested try if you need to swallow a fallible call inside a
cleanup:
defer_err {
try { rollback() } catch _ {
log.error("rollback failed; check transaction log")
}
}
Suppressed Errors #
If a cleanup itself panics on the error path, the original error is
preserved and the cleanup failure is attached as .suppressed:
use std::Result
struct AppError { message: str }
fn op() -> Result<int, AppError> {
defer_err |_e| panic("cleanup-fail")
Result.Err(AppError { message: "primary" })
}
match op() {
Result.Ok(v) => print("ok: {v}"),
Result.Err(e) => {
print(e.message) // "primary" — root cause kept
print(e.suppressed[0]) // "cleanup-fail" — attached
},
}
The mechanism only attaches when the payload is a struct/table —
primitive errors (str, int) have nowhere to hang the field.
Top-Level Defer #
Only the plain defer is allowed at module scope; it fires once at
program exit in LIFO order:
defer print("program ended") // runs last
print("hello")
defer_ok / defer_err at module scope are rejected with
E_DEFER_004 — the module's "exit value" isn't well-defined for the
ok/err distinction. Use on panic { ... }
for global failure cleanup instead.
Diagnostics Reference #
| Code | When |
|---|---|
E_DEFER_003 |
return / break / continue / ? inside a defer body |
E_DEFER_004 |
defer_ok / defer_err used at module top level |
E_DEFER_007 |
defer_ok |v| binding in a function with unit return |
E_DEFER_009 |
typed binding incompatible with the enclosing return type |
W_DEFER_006 |
defer_err in a function that statically cannot reach an error exit |
See specs/defer-ok-err.md for the full
specification including the lowering details.
Let-Else #
let-else is a pattern that binds a value from a pattern match, or runs an else block (which must diverge — i.e., return, break, continue, or panic):
fn process_config(raw: str?) {
let Some(config) = raw else {
print("no config provided")
return
}
// 'config' is available here as str
parse(config)
}
With Enum Variants #
use std::Result
fn handle_result(r: Result<int, str>) {
let Result.Ok(value) = r else {
print("operation failed")
return
}
print("got value: {value}")
}
Compared to if let
let-else is the inverse of if let. Use if let when the happy path is nested; use let-else to bail out early and keep the happy path flat:
// if let — happy path is nested
if let Some(value) = get_value() {
process(value)
}
// let-else — happy path is flat (preferred for early returns)
let Some(value) = get_value() else { return }
process(value)
While Let #
while let loops as long as a pattern matches:
// Process items until the queue is empty
while let Some(item) = queue.pop() {
process(item)
}
// Consume an iterator manually
let iter = some_iter()
while let Some(val) = iter.next() {
print(val)
}
With Result #
// Read lines until error
while let Result.Ok(line) = reader.next_line() {
handle(line)
}
Guard #
guard provides an early-exit precondition with a bound variable available after the check:
fn send_email(to: str?, body: str) {
guard let address = to else {
print("no recipient")
return
}
// 'address' is str here (unwrapped)
email_client.send(address, body)
}
guard works with any pattern:
fn process_user(data: {str: any}?) {
guard let Some(d) = data else { return }
guard let name = d["name"] as str? else { return }
print("Processing: {name}")
}
Loop with Break Value #
loop can return a value via break:
let result = loop {
let input = read_input()
if is_valid(input) {
break input // loop evaluates to 'input'
}
print("invalid, try again")
}
print("You entered: {result}")
Labeled Breaks and Continues #
For nested loops, use labels to break or continue an outer loop:
39;outer: for i in 0..5 {
for j in 0..5 {
if i + j > 6 {
break 39;outer // exits both loops
}
print("{i},{j}")
}
}
39;outer: for i in 0..5 {
for j in 0..5 {
if j == 2 {
continue 39;outer // skips to next iteration of outer loop
}
print("{i},{j}")
}
}
Range Expressions #
Ranges are first-class values in Zolo:
use std::Array
use std::Iter
// Exclusive range (0 to 9)
for i in 0..10 {
print(i)
}
// Inclusive range (0 to 10)
for i in 0..=10 {
print(i)
}
// Infinite range (lazy — use with Iter.take)
let first5 = (0..)
|> Iter.take(5)
|> Iter.collect()
print(first5) // [0, 1, 2, 3, 4]
// From-start range (up to but not including 10)
let head = Array.filter([1, 2, 3, 4, 5, 6], |i| i in ..3)
// Ranges in match
fn classify(n: int) -> str {
match n {
0 => "zero",
1..=9 => "single digit",
10..=99 => "double digit",
_ => "large",
}
}
Range as Value #
use std::Iter
let r = 1..=5
print(r.contains(3)) // true
print(r.contains(6)) // false
// Collect a range
let nums = (1..=5) |> Iter.collect()
print(nums) // [1, 2, 3, 4, 5]
Comprehensive Pattern Example #
Combining defer, let-else, guard, and try/catch for robust code:
async fn handle_request(req: Request) -> Response {
let conn = db.connect()
defer conn.close()
// Validate input early
let Some(user_id) = req.params["user_id"] else {
return Response.bad_request("missing user_id")
}
let parsed_id = try {
parse_int(user_id)
} catch _ {
return Response.bad_request("invalid user_id")
}
guard let user = await db.find_user(parsed_id) else {
return Response.not_found("user not found")
}
// Happy path — clean and flat
let data = await fetch_user_data(user)
return Response.ok(data)
}