Skip to content
On this page

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. It checks a boolean condition; it does not bind a pattern:

fn send_email(to: str?, body: str) {
    guard to != nil else {
        print("no recipient")
        return
    }
    let address = to ?? ""
    print("send to {address}: {body}")
}

send_email("ada@example.com", "hello")
send_email(nil, "ignored")

Several conditions can be checked in sequence. After a nil check, ?? extracts the optional value:

fn process_user(name: str?, active: bool) {
    guard name != nil else { return }
    guard active else { return }
    let value = name ?? ""
    print("Processing: {value}")
}

process_user("Ada", true)
process_user("Bo", false)

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:

'outer: for i in 0..5 {
    for j in 0..5 {
        if i + j > 6 {
            break 'outer   // exits both loops
        }
        print("{i},{j}")
    }
}
'outer: for i in 0..5 {
    for j in 0..5 {
        if j == 2 {
            continue '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")
    }

    let maybe_user = await db.find_user(parsed_id)
    guard maybe_user != nil else {
        return Response.not_found("user not found")
    }
    let user = maybe_user ?? panic("guard invariant violated")

    // Happy path — clean and flat
    let data = await fetch_user_data(user)
    return Response.ok(data)
}

Search Zolo

9 results

en