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 playgroundEvery 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 playgroundx |> 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
ResultorOption - 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.