/ Specification
Playground Docs Performance GitHub
Chapter 8

Error Handling

Lattice provides three complementary mechanisms for error handling: exception-based try/catch, guaranteed cleanup with defer, and value-based result propagation with the ? operator.

Try/Catch

try_catch  ::= "try" block "catch" identifier block

A try/catch expression executes the try block. If a runtime error occurs, execution transfers to the catch block with the error value bound to the identifier.

The try/catch expression produces a value: the value of the try block on success, or the value of the catch block on error.

let result = try {
    let data = json_parse("invalid json")
    data.get("name")
} catch e {
    print("Error: ${e}")
    "default"
}

Runtime Errors

Runtime errors are raised by invalid operations (e.g., type mismatches, index out of bounds, crystal assignment) and by failed require/ensure contracts. These errors propagate up the call stack until caught by a try/catch block. If uncaught, the program terminates with an error message.

The error() built-in creates an error result map (with "tag": "err" and "value" fields), which can be used with the ? operator for value-based error handling. The is_error() built-in checks whether a value is an error map.

fn divide(a: Int, b: Int) -> Map {
    if b == 0 {
        return error("division by zero")
    }
    return ok(a / b)
}

Defer

defer_stmt  ::= "defer" block

A defer statement schedules a block to be executed when the enclosing scope exits. Deferred blocks run regardless of whether the exit is normal or due to an error. This makes defer ideal for cleanup operations like closing file handles or releasing resources.

Multiple defers in the same scope execute in LIFO (last-in, first-out) order: the most recently deferred block runs first.

fn with_file(path: String) {
    let fd = open(path)
    defer { close(fd) }           // 3rd: close file
    defer { print("done") }        // 2nd: log
    defer { print("cleaning up") } // 1st: log

    // ... work with fd ...
    // All defers run when this scope exits
}
Note Deferred blocks execute even when an error occurs, making them reliable for resource cleanup.

Result Type Pattern

Lattice uses a map-based convention for result values. The built-in error(value) creates a map with "tag": "err" and "value" fields. The built-in is_error(value) checks for this tag.

The standard library (lib/fn.lat) provides additional helpers: ok(value), err(value), is_ok(result), and is_err(result). These must be imported before use.

fn parse_int(s: String) -> Map {
    let n = to_int(s)
    if n == nil {
        return error("not a number: ${s}")
    }
    // Return a success map
    flux result = Map::new()
    result.set("tag", "ok")
    result.set("value", n)
    return result
}

// error(value) creates {"tag": "err", "value": value}
// is_error(value) checks if value has tag "err"

Try-Propagate Operator (?)

try_propagate  ::= expression "?"

The postfix ? operator works with result maps. If the expression evaluates to a map containing an "err" key, the error is immediately returned from the enclosing function as an error result. Otherwise, the "ok" value is extracted and becomes the expression's value.

fn load_config() -> Map {
    let raw = read_file("config.json")?  // unwrap or return err
    let parsed = json_parse(raw)?          // unwrap or return err
    return ok(parsed)
}

This provides ergonomic error propagation without explicit branching.

Optional Chaining

Optional chaining operators provide safe navigation through potentially nil values:

OperatorDescriptionEquivalent
x?.fieldAccess field if x is not nilif x != nil { x.field } else { nil }
x?.method()Call method if x is not nilif x != nil { x.method() } else { nil }
x?[i]Index if x is not nilif x != nil { x[i] } else { nil }

Optional chaining composes with the nil-coalesce operator ?? to provide defaults:

let city = user?.address?.city ?? "Unknown"
let first = items?[0]?.name ?? "none"