Error Handling

No Exceptions

Functions that can fail return Result(value, error). Values that might be absent are Option(value). Errors are values — visible in types, mandatory to handle:

match parse_int(input) {
  Ok(n) -> use(n)
  Err(e) -> handle(e)
}

A Result that stands as a statement cannot be dropped by accident: with nothing looking at it, it is an error, this `Result` is unused: an error in it would go unseen; handle it, return it with `?`, or write `let _ = ...` .

Typed Stdlib Errors

Every fallible stdlib module returns a typed error enum — IoError, JsonError, TomlError, IntError, FloatError, HttpError, TcpError, PostgresError, TimeError, BytesError, EncodingError, CryptoError, UuidError, RegexError and TaskError; no function returns a Result(_, String). Each is named after its module and is a member of it (io.IoError), and so are its variants: io.NotFound, json.Syntax, http.Timeout, and so on; the last variant of each is Other(String). The full catalog is in docs/stdlib/errors.md; hovering over an error type in your editor shows the same table.

You can either pattern-match on a specific variant, or fall back to .message() for a rendered string:

import io
import string

fn main() {
  match io.read_file("app.json") {
    Ok(content) -> println("loaded {string.length(content)} bytes")
    Err(io.NotFound(path)) -> println("file does not exist: {path}")
    Err(io.PermissionDenied(path)) -> println("denied: {path}")
    Err(e) -> println("error: {e.message()}")
  }
}
open in playground

Every stdlib error enum implements the built-in Error trait, which has Display as a supertrait and provides message(self) -> String. String interpolation "{e}" and e.message() produce the same text — silt routes the Display impl through Error::message for stdlib error enums so the two shapes never drift.

The ? Operator

? propagates errors to the caller unchanged. It works on both Result and Option, and it does not convert between error types — the inner Err type of the ?-ed expression must match the enclosing function’s Err type:

import io

fn read_head(path: String) -> Result(String, io.IoError) {
  let content = io.read_file(path)? -- Err(IoError) propagates
  Ok(content)
}

? and the pipe operator

A ? that ends a pipeline applies to the whole pipeline — no parentheses needed:

import io
import result

type Wrap {
  Wrap(io.IoError),
}

fn load(path: String) -> Result(String, Wrap) {
  let raw = io.read_file(path) |> result.map_err { e -> Wrap(e) }?
  Ok(raw)
}

fn main() {
  match load("config.toml") {
    Ok(raw) -> println("loaded")
    Err(_) -> println("no config")
  }
}
open in playground

x |> f |> g? parses as (x |> f |> g)?. On the other side, produce()? |> double still works too — the ? on the left is already attached by the time the pipe sees the value.

Everywhere else ? binds tightly, like a call: it applies to the operand right before it. int.parse(a)? + int.parse(b)? adds two unwrapped values, a == b? is a == (b?), and -x? is -(x?). To unwrap the result of an infix expression, parenthesise it: (a + b)?.

Err escaping main

main may itself return a Result, so ? works at the top level too. When the program ends with an Err(..) — whether returned directly or propagated out of main by ? — silt run treats it as a failed program: it prints an error[runtime]: main returned Err: ... diagnostic (with the payload rendered the same way println would print it) to stderr and exits with status 1, the same exit code as any other runtime error. Ok(..) returns, and main functions that don’t return a Result at all, exit 0 as usual.

Cross-Module Error Composition

? does not convert between typed errors, so a function that calls several stdlib modules — each returning its own error enum — needs a local wrapper. The canonical pattern: declare an AppError enum, lift each call via result.map_err, and let ? propagate uniformly:

import http
import io
import json
import result

type Config {
  api_url: String,
  api_key: String,
}

type AppError {
  ConfigRead(io.IoError),
  ConfigParse(json.JsonError),
  ApiCall(http.HttpError),
}

fn load_and_fetch(path: String) -> Result(Bytes, AppError) {
  let contents = result.map_err(io.read_file(path)) { e -> ConfigRead(e) }?
  let config = result.map_err(json.parse(contents, Config)) { e -> ConfigParse(e) }?
  let resp = result.map_err(http.get(config.api_url)) { e -> ApiCall(e) }?
  Ok(resp.body)
}

Variant constructors like Wrap or ConfigRead are first-class Fn values, so result.map_err(r, ConfigRead) also works with no closure wrapping.

See examples/cross_module_errors.silt for the full worked example.

when let-else for Custom Errors

When you need a custom error message or destructuring that goes beyond ?:

fn parse_config(text) {
  let lines = text |> string.split("\n")

  when let Some(host_line) = lines |> list.find { l -> string.contains(l, "host=") } else {
    return Err("missing host in config")
  }
  let host = host_line |> string.replace("host=", "")

  when let Some(port_line) = lines |> list.find { l -> string.contains(l, "port=") } else {
    return Err("missing port in config")
  }
  when let Ok(port) = port_line |> string.replace("port=", "") |> int.parse() else {
    return Err("invalid port number")
  }

  Ok("connecting to {host}:{port}")
}

Choosing Between ? and when let-else

Use ? when you want to propagate the error unchanged. Use when let-else when you need to:

  • Provide a custom error message
  • Destructure something other than Result or Option
  • Combine pattern and boolean guards in a flat sequence
-- Simple propagation: use ?
let value = parse(input)?

-- Custom error message: use when let-else
when let Ok(value) = parse(input) else {
  return Err("failed to parse input: expected integer")
}

-- Mixed pattern and boolean guards
fn process(input) {
  when let Ok(value) = parse(input) else { return Err("parse failed") }
  when value > 0 else { return Err("must be positive") }
  Ok(value * 2)
}

Never Type

return and panic() produce the Never type, which unifies with any type. This lets them appear in any expression position:

fn get_or_die(opt) {
  match opt {
    Some(v) -> v
    None -> panic("expected a value") -- Never unifies with v's type
  }
}

Result and Option Utilities

println(result.map_ok(Ok(1)) { x -> x + 1 }) -- Ok(2)
println(result.flat_map(Ok(1)) { x -> Ok(x + 1) }) -- Ok(2)
println(result.unwrap_or(Err("x"), 0)) -- 0

println(option.map(Some(1)) { x -> x + 1 }) -- Some(2)
println(option.flat_map(Some(1)) { x -> Some(x + 1) }) -- Some(2)
println(option.unwrap_or(None, 0)) -- 0

result.flat_map is symmetric with option.flat_map — both take a value and a function that returns a wrapped result, and flatten the nesting.