November 2024 · 7 min read

Error Handling Done Right

Exceptions are invisible control flow. Lateralus makes errors visible, composable, and impossible to ignore.

The problem with exceptions

In most languages, any function can throw. You don't know which ones will until runtime. The call stack unwinds silently, skipping cleanup code, corrupting state. Checked exceptions (Java-style) tried to fix this but created a different mess — boilerplate catch blocks, forced wrapping, and throws Exception everywhere.

Go's approach (if err != nil) is explicit but verbose. Rust's Result<T, E> with ? is closer to what we want, but Lateralus takes it further with pipeline-native error propagation.

Result types in Lateralus

Every fallible function returns Result<T, E>. There are no exceptions. The type system forces you to handle the error case:

fn parse_port(s: String) -> Result<Int, ParseError> {
    let n = s |> to_int()?
    if n < 1 || n > 65535 {
        Err(ParseError::OutOfRange("Port must be 1-65535"))
    } else {
        Ok(n)
    }
}

The error pipeline operator |?>

The standard pipeline |> passes values through. The error pipeline |?> short-circuits on Err:

let config = read_file("config.toml")
    |?> parse_toml()
    |?> validate_config()
    |?> apply_defaults()

If any step returns Err, the chain stops and the error propagates. No nesting, no if err checks.

try / recover / ensure

For structured error handling with cleanup:

try {
    let db = Database::connect(url)?
    let rows = db.query("SELECT * FROM users")?
    rows |> map(format_user) |> println_each()
} recover err {
    match err {
        DbError::Connection(msg) => log::error("DB down: {msg}"),
        DbError::Query(msg)      => log::error("Bad query: {msg}"),
        _                        => log::error("Unknown: {err}"),
    }
} ensure {
    db.close()  // Always runs, like finally
}

Custom error types

Define domain errors as enums. The compiler enforces exhaustive matching:

enum AppError {
    NotFound(String),
    Unauthorized,
    RateLimit { retry_after: Int },
    Internal(String),
}

Why this matters

Every error path is visible in the type signature. The compiler catches unhandled cases. Pipelines compose cleanly even with errors. And ensure guarantees cleanup without the fragility of destructors or defer statements.

Read the formal specification for the complete error handling semantics, or try it in the playground.

Error propagation in pipelines

The |?> operator is where error handling and pipelines intersect. It propagates errors through pipeline stages automatically:

// Without |?> — verbose error handling at each step:
let data = match read_file("config.toml") {
    Ok(d) => d,
    Err(e) => return Err(e),
}
let parsed = match toml::parse(data) {
    Ok(p) => p,
    Err(e) => return Err(e),
}
let validated = match validate(parsed) {
    Ok(v) => v,
    Err(e) => return Err(e),
}

// With |?> — errors propagate automatically:
let validated = read_file("config.toml")
    |?> toml::parse()
    |?> validate()

Each |?> stage checks the Result. If it's Ok, the value is unwrapped and passed to the next stage. If it's Err, the entire pipeline short-circuits and returns the error. It's the pipeline equivalent of Rust's ? operator.

Custom error types

Lateralus encourages defining domain-specific error types rather than using generic strings:

enum ScanError {
    Timeout(host: String, duration: Duration),
    ConnectionRefused(host: String, port: u16),
    DnsResolutionFailed(hostname: String),
    Unauthorized(host: String, reason: String),
}

// Pattern match on errors for precise handling:
match nmap_scan(target) {
    Ok(results) => process(results),
    Err(Timeout(host, dur)) => {
        log::warn("Scan timed out after {dur}: {host}")
        retry_with_longer_timeout(host)
    },
    Err(ConnectionRefused(host, port)) => {
        log::info("Port {port} closed on {host}")
        skip_host(host)
    },
    Err(e) => return Err(e),  // Propagate other errors
}

The recover combinator

For pipelines where you want to handle errors mid-stream without breaking the chain, there's recover:

let results = targets
    |> map(|t| nmap_scan(t))           // Vec<Result<ScanResult, ScanError>>
    |> recover(|err| match err {       // Handle errors inline
        Timeout(h, _) => retry(h, timeout: 60.seconds()),
        _ => Err(err),                 // Re-raise other errors
    })
    |> filter_ok()                     // Keep only successes
    |> unwrap_all()                    // Vec<ScanResult>

Error context and chaining

Real-world errors need context. Lateralus supports error chaining with the context method:

fn load_config(path: String) -> Result<Config, Error> {
    read_file(path)
        |?> toml::parse()
        |?> validate()
        .context("Failed to load config from {path}")
}

// Error output:
// Error: Failed to load config from /etc/nullsec/config.toml
// Caused by: TOML parse error at line 42
// Caused by: Expected '=' after key 'database.host'

Each .context() wraps the inner error with additional information. When the error is displayed, the full chain is printed from outermost to innermost, giving you both the high-level context and the specific cause.

Comparison with other approaches

Lateralus is built by bad-antics. Follow development on GitHub or try the playground.