09 - Error Handling
Verge doesn’t do exceptions. Errors are ordinary values, which is useful: they’re visible in the types, hard to ignore by accident, and there’s no hidden control flow lurking in every call. Panicking is reserved for the genuinely unrecoverable.
The error type
A function that can fail returns one of two shapes:
erroralone, when there’s no success value —nullmeans success.(T, error), when there’s both a result and a possible failure.
Build an error with error.new(message). It’s a built-in, so no import is required:
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
main: f() {
result, err := div(10, 2)
if err != null {
io.writeln("failed: {err}")
} else {
io.writeln("result: {result}")
}
}
result: 5
Two things to notice: null is the “no error” value, and an error prints as its message
when interpolated ("{err}") — you don’t have to reach for .message() just to log it,
though err.message() is there when you want the string explicitly.
Handling errors by hand
The most explicit style unpacks the tuple and checks the error:
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
describe: f(a, b: i32) => string {
result, err := div(a, b)
if err != null {
return "error: {err}"
}
return "ok: {result}"
}
main: f() {
io.writeln(describe(10, 2))
io.writeln(describe(10, 0))
}
ok: 5
error: division by zero
Perfectly readable, but repetitive when every call does the same thing on failure. That’s
what !! is for.
The !! operator
!! is the ergonomic shortcut for the common error-handling patterns. It comes in several
forms, all built around “if the left-hand side failed, do this”.
Propagate the error
Bare !! unwraps the success value, or returns the error from the current function
immediately (so the function must itself return an error or (T, error)):
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
double_div: f(a, b: i32) => (i32, error) {
value := div(a, b)!! # on failure, return the error from double_div
return (value * 2, null)
}
main: f() {
v, err := double_div(10, 2)
io.writeln("value {v}, err {err}")
}
value 10, err
(The trailing space is the empty message of the null error printing as nothing.)
Supply a fallback value
Follow !! with a value to use when the call fails:
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
main: f() {
safe := div(10, 0) !! -1 # division fails, so safe becomes -1
io.writeln("safe: {safe}")
}
safe: -1
Return a custom result
!! return <value> leaves the whole function on failure. The returned value must match the
function’s return type, so in a (T, error) function you return the full tuple:
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
checked: f(a, b: i32) => (i32, error) {
value := div(a, b) !! return (0, error.new("calculation failed"))
return (value, null)
}
main: f() {
_, err := checked(1, 0)
io.writeln("checked: {err}")
}
checked: calculation failed
Jump out of a loop
Inside a loop, !! can continue (skip this iteration) or break (leave the loop):
import std.io
div: f(a, b: i32) => (i32, error) {
if b == 0 {
return (0, error.new("division by zero"))
}
return (a / b, null)
}
sum_valid: f(values: []i32) => i32 {
total := 0
for v := values {
part := div(100, v) !! continue # skip divisors that fail
total += part
}
total
}
main: f() {
io.writeln("sum: {sum_valid([]i32{2, 0, 4})}") # skips the 0
}
sum: 75
Structured error kinds
A message is fine for humans, but sometimes code needs to branch on which error occurred.
Pass an enum value as the first argument to error.new to attach a machine-readable kind:
import std.io
NetworkError: enum {
Timeout
Refused
NotFound
}
main: f() {
err := error.new(NetworkError.Timeout, "connection timed out")
kind, ok := err.kind<NetworkError>()
if !ok {
io.writeln("no network kind attached")
} else {
desc := match kind {
Timeout => "retrying after timeout"
Refused => "connection refused"
NotFound => "not found"
}
io.writeln("{desc} ({err})")
}
}
retrying after timeout (connection timed out)
err.kind<K>() returns (K, bool) — the kind and whether a kind of type K was actually
set. The bool matters: an error built without a kind returns the enum’s zero value with
false, so you never mistake “no kind” for a genuine first-variant match.
Wrapping and chaining
When an error bubbles up, the original cause is often worth keeping alongside extra context.
error.wrap(cause, message) layers a new message over an inner error, and err.unwrap()
retrieves the cause:
import std.io
read_config: f(path: string) => error {
if path == "" {
return error.new("no such file")
}
return null
}
load: f(path: string) => error {
err := read_config(path)
if err != null {
return error.wrap(err, "failed to load configuration")
}
return null
}
main: f() {
err := load("")
if err != null {
io.writeln("outer: {err}")
cause := err.unwrap()
if cause != null {
io.writeln("cause: {cause}")
}
}
}
outer: failed to load configuration
cause: no such file
err.unwrap() returns the wrapped inner error, or null if the error wasn’t created with
error.wrap.
defer for cleanup
defer schedules a statement to run when control leaves the enclosing block — whether it
falls off the end, returns, breaks, or continues. Multiple defers run in LIFO order,
which makes it ideal for cleanup that must happen no matter how you leave:
import std.io
main: f() {
defer io.writeln("cleanup runs last")
defer io.writeln("...then this")
io.writeln("working")
}
working
...then this
cleanup runs last
Because defer is scoped to the enclosing block, a defer inside a loop runs at the end of
each iteration — so pairing an open with a defer close cleans up the right handle every
time round.
A practical rule of thumb
Use the manual tuple style when the handling is genuinely specific; reach for !! when it’s
routine and uniform; attach a kind when code needs to branch on the failure; wrap when
context matters; and defer any cleanup that must not be missed. That’s the whole model:
explicit, visible, and predictable.