Skip to content

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)
}
enespt-br