Skip to content

Specification §7

Error handling

language-design.md §7 · 182 lines · 14 min read

Errors are explicit values. The developer has to handle them. There are no exceptions, there is no implicit propagation.

A failure has two independent questions, and the language gives a construct for each, instead of fusing the two:

  • Result[T, E]: the resolution, that is, it succeeded or not. It is a builtin enum, { Ok(T), Err(E) }, that reuses the sum type of section 2, with no new machinery.
  • error{...}: the union of errors, that is, which failures exist. A structural and closed error-set that the compiler knows exactly.

The two compose: the E of a Result is an error{...}. Keeping them separate is what gives automatic error composition (below) without mixing the success value with the failure variants in the same union:

fn open(path: string) -> Result[File, error{NotFound, PermissionDenied}]

Matching the whole Result as subject covers the Ok and each error variant: exhaustive, and forgetting a case is a compile error. It is one level (nested pattern Err(error.X)), not two nested matches. This is the fork form: success and error end right there (both become, say, a Response):

match open(path) {
Ok(f) => use(f)
Err(error.NotFound) => ...
Err(error.PermissionDenied) => ...
}

Closed domain _ is a compile error: you cover all the variants, and adding a new one breaks the build until you handle it (the safety net; a _ here would nullify it, falling into the new variant silently). For the sequential case, where success continues and only the error needs attention, use the trailers below (there the Ok is resolved by the assignment, and the error already comes unwrapped: you match error.X directly, without the Err()).

Error trailers: catch (unit) and match |e| (branches)

Section titled “Error trailers: catch (unit) and match |e| (branches)”

In the sequential case the Result appears as a trailer of an operation. The Ok is resolved by the assignment at the start of the line (you never handle the Ok by hand), and the trailer deals only with the error. Two trailers, both bind the error with |e|:

catch |e| { block } handles the error as a unit: propagate, panic, log. Any E. The block is a statement and never produces the v. In a value-binding (v := op() catch |e|) this forces the handler to escape: success gives the Ok to v, and since the error has no value to give, return leaves the function (propagates), break leaves the loop, panic aborts. (In a pure statement, with no v to fill, like the catch of the spawn in section 8, there is nothing to leave empty, so the handler reacts and execution continues; diverging is allowed, not required.) return here is an early-return, there is no implicit return; and it is propagation without a ? operator (the ? is “magic” that encourages laziness; here you see what happens):

v := do_something(x) catch |e| { return Err(e) } // propagate, explicit
v := do_something(x) catch |e| { return e } // propagate, sugar → Err(e)
v := do_something(x) catch |e| { runtime.panic(e) } // abort

The return e sugar wraps in Err() only when the return is a Result[_, E], any Ok with error E (the _ is the success type, not “no value”: absence-of-value is not a Result, it is the error-set alone), and the compiler knows statically that e is an error-set ⊆ E. It is not a general T → Result coercion; it is “an error, in a context that expects an error, becomes Err”. Success is always explicit (return Ok(x)): a purposeful asymmetry, where the propagated error is the common path and gets sugar, while success declares itself, so that return x is never ambiguous between an ok-value and an error-value.

match |e| { arms } branches by error variant. It only makes sense when E is a union (error{E1, E2, …}); like catch, it is a statement and does not produce the v (on an error there is no Ok), exhaustive over the variants. In a value-binding each arm escapes (return/break/panic); in a pure statement it reacts and continues, the same rule as catch above:

v := op() match |e| { // E = error{E1, E2, E3}
E1 => { log(e); return Err(e) } // handle one specially: log and propagate
E2 | E3 => runtime.panic(e) // group the rest with '|' (closed → no '_')
}

It is sugar for catch |e| { match e { arms } }: the redundant catch and the repeated e vanish, and what is left is the match (over the error) that was always there. The catch-vs-match choice is exactly “handle as a unit (catch) vs branch by case (match)”.

The trailer escapes; recovering-with-a-value is match over the Result. The trailers (catch/match |e|) never give a value to the v: they handle the error and leave. When the error should produce a value (a default that becomes the v), the construct is match over the whole Result (the fork above): the Err(...) arm does return <value> and delivers it to the v just like the Ok. Putting recovery-with-a-value in a trailer is the easy mistake (this document has already made it), because the trailer is for leaving, and match over Result is for resolving with a value.

Rule: match |e| over a single-variant (non-union) error is a compile error. There is nothing to branch on, so use catch. Branching capability declared and not exercised is noise, on the same ruler as the never-mutated var (section 6) and the unnecessary deep (section 5).

Set of variants: the subset of a closed sum

Section titled “Set of variants: the subset of a closed sum”

error{NotFound, Timeout} was never a construct exclusive to errors: it is a sum-domain with a subset of variants. error is only the builtin domain of open variants (any tag); a declared enum is a domain of fixed variants. The two sub-set themselves with the same notation Domain{variants}:

decl Event { Send(Msg); Receive(Msg); Spawn(Pid); Exit(Reason); ChannelBlock(Chan) }
Event{Send, Receive, ChannelBlock} // an Event, guaranteed to be one of these three
error{NotFound, Timeout} // the same concept, in the builtin domain 'error'

(In a type position, Name{...} is a set of variants, like Event{Send}; in a value position, it is the struct literal, like Vec2{1; 2}, section 14. They do not collide: the position separates, and the kind reinforces, since an enum has no struct literal and a struct has no set of variants.)

Hence the open/closed symmetry: a bare error is open (open variants matches with _), a bare Event is closed (fixed variants exhaustive without _), and {...} closes either one into a subset. The limit-case closes the system: {} with zero variants is the empty set, uninhabited, no tag constructible. The subset fits in only one direction, from the narrow to the wide:

Event{Send} ⊆ Event{Send, Receive} ⊆ Event

Constructing a variant gives the narrowest set, which widens on its own to any superset (just as Err(error.NotFound) becomes error{NotFound} and fits in any larger error-set):

e := Event.Send(msg) // type: Event{Send}
f(e) // OK if f asks for Event{Send, Receive}

Composing is uniting: the spread name... (postfix; the variadic is prefix ...int, section 13) joins the variants, the same for error and enum:

fn open(p) -> Result[File, error{NotFound, PermissionDenied}]
fn parse(f) -> Result[Data, error{ParseError, Encoding}]
fn load(p) -> Result[Data, error{open..., parse...}] { // {NotFound, PermissionDenied, ParseError, Encoding}
f := open(p) catch |e| { return e } // error{NotFound, PermissionDenied} ⊆ E → fits
d := parse(f) catch |e| { return e }
return Ok(d)
}

Matching a subset is exhaustive over the subset: it covers exactly those variants (without _), and an arm for an outside variant is an error (unreachable). Narrowing widenarrow costs a match at runtime; widening is free. In a signature, the set bars the boundary at the type: fn on_traffic(e: Event{Send, Receive}) refuses an Exit without a runtime check.

Because an error-set is just a nominal sum, its variants carry payload inline exactly like any other variant (section 7): an inline literal such as error{HTTP500, PortError(int)} is well-formed, with PortError opening to its int under match just as Circle(float) does — you are not forced to model a payloaded error as a separate decl enum in the E slot. And the free-widening sugar of return e (the catch |e| { return e } above, which wraps e into Err and widens the narrow set to the declared superset) applies uniformly: it does not matter whether the function’s E is an inline error{…} or a declared enum used as the error type — a returned narrow error widens to the wider E the same way in both cases.

This is not general subtyping: it is the restricted relation that errors always had (“one set fits in another larger one”, closed domain, checked statically), now extended to any nominal sum.

The empty error set, error{}, says “does not err”. Since {} is uninhabited, a Result[T, error{}] has the Err unconstructible: the function never fails. This serves honesty at the interface boundary: a concrete that satisfies a fallible interface (fn read() -> Result[usize, error]) but never errs declares error{}, and fits, because error{} ⊆ any error-set (the empty fits in all). The reader sees in the signature that the Err never occurs, without the type needing to invent an error that does not happen nor the concrete giving up on fulfilling the contract. It is a different question, in a different position, from noreturn (section 14): error{} answers “is there a possible error?” (the E slot); noreturn answers “does control return to the caller?” (the return position). Both are uninhabited underneath, but distinct on the surface, and that is why they get separate names.

Opening a variant: the pattern and its binding

Section titled “Opening a variant: the pattern and its binding”

A variant can carry a value: Send(Msg) carries a Msg, Circle(float) a float, Rect(float, float) two, Quit nothing. It is what separates this sum from the constant enum (Go, C): there enum State { Running, Paused } are labels without data, and the value is just “which label”; here each variant is like its own struct behind a label, and the constant-enum is the particular case where no variant carries anything. Two points usually trip up whoever comes from there:

The variable is of the sum’s type, not the variant’s. When a value has type Shape (say s: Shape = pick(), from fn pick() -> Shape), Circle is not its type, it is one of the forms a Shape can have. That is why a match on it covers Rect too: the compiler looks at the type (Shape, which admits both forms), not the line, and in general only at runtime is the form known. Exhaustiveness is of the type. (Building a variant directly gives the narrowest type, not the whole sum: s := Shape.Circle(2.0) has type Shape{Circle}, which widens to Shape on its own or by annotation, section 7. So the two-arm match below annotates s: Shape to hold any form; a bare s := Shape.Circle(2.0) would be a Shape{Circle}, matched by the Circle arm alone.)

The name inside the pattern is a new binding, not something from outside. The same variant, constructing and opening:

decl Shape { Circle(float); Rect(float, float) } // DECLARES: the form and what it carries
s: Shape = Shape.Circle(2.0) // CONSTRUCTS: fills the "box" Circle with 2.0 (typed Shape to hold any form)
match s {
Circle(r) => area(r) // OPENS: if it is Circle, call 'r' what is inside
Rect(w, h) => w * h // OPENS: pull the two values as 'w' and 'h'
}

In construction (Circle(2.0)) the parenthesis is input: you push 2.0 in. In the pattern (Circle(r)) it is output: the content comes out and gets the name r. The same core Circle(...), opposite direction; only the position (to the left of the =>, in a match) says which it is. (In construction you name the type, Shape.Circle; in the pattern the Shape. vanishes, because the match already fixed that s is a Shape.) The r is a fresh binding that holds only in that arm, like the x of loop x in xs, the e of catch |e|, the f of fn(f) => …: a name introduced by position, without var/:=. And it is the guarded access to the payload: r only exists in the arm where you proved the Circle form, so there is no way to read the float of a Rect (it is not even there). What separates the two names is position, not capitalization. The head name (Circle, bare to the left of the parens or standing alone as the whole pattern) is matched against the subject’s variants by exact, case-sensitive identity; a name inside the parens (payload position) is a fresh binding. Capitalization is only a naming convention and carries no meaning of its own: circle and Circle are simply different names, so a head name that does not spell one of the subject’s variants is an error (“not a variant of Shape”), never a silent catch-all binding. The one catch-all is _; to capture a computed subject under a name, bind it first with let r := <expr> and then match. When the payload is irrelevant, _ matches the form and discards; | matches several forms at once.

Whoever filled the box may not be you, and it is what you see reading the stdlib. The derivation of serialize (section 10) matches over reflect(T).kind, a sum value that the compiler assembled, already in the right form for T:

comptime match reflect(T).kind {
Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) }
Tuple(parts) => comptime loop p in parts { serialize(p.get(v), out) }
Slice(_) | Array(_, _) => loop x in v { serialize(x, out) }
Int(_) | Float(_) | Bool => out.write(v.to_bytes())
... // Enum, Optional, String, and the fails (full mold in section 10)
}

If T is a struct, the compiler delivered the kind in the Struct form carrying the struct’s descriptor. Struct(s) opens this and calls the descriptor s, and that is why s.fields (the field list) works inside the arm. The s does not bring its own data: it receives what the kind already carried, just like the r of Circle(r), only that whoever filled the box was reflect, not a Circle(2.0) of yours. The other arms are the same operation: Tuple(parts) opens into the list of positional elements, Slice(_) matches the form and discards the content, Int(_) | Float(_) | Bool matches three forms without looking at the payload. Always the same gesture: match the form and, if you want, name what it carries.

  • catch |e| { return e } (or return Err(e)) propagate, concise.
  • catch |e| { ... } handle as a unit and leave (panic, or log-and-propagate). Every path diverges.
  • match |e| { ... } handle branching and leave by variant. Union error; every arm diverges.
  • match result { Ok/Err } fork / recover: handles success and error together and is the one that produces the value (each arm does return of the value to the variable). It is here, not in the trailer, that you recover with a fallback.

Success without a value: the error-set alone

Section titled “Success without a value: the error-set alone”

An operation that succeeds without producing a value does not use Result, because the language has no unit, so there is no Result[(), E]. It returns only the error-set: fn send(...) -> error{ProcessDown}. The type says “this failure, or nothing (success)”: success is the absence of error, with no payload and no null, because the value is tagged (either an error tag, or the success tag, which carries nothing).

Since there is no value, there is no Ok arm: you use a trailer, and success is the falling-through (execution continues to the next line):

chan <- data catch |e| { return Err(e) } // error → propagate; success → continue (nothing to bind)

To branch by error variant (react to one, propagate another), use match |e| (section 14); an arm that retries diverges with continue. A local chan <- data here has only the terminal ProcessDown (a full buffer blocks the sender, section 5, rather than returning an error), so you propagate it; an operation with a genuinely retriable error is the loop-and-continue shape.

This is the “error-as-value” of process failures: sending to a dead process throws nothing, it returns error.ProcessDown, handled like any other error. (There is no FullBuffer error: a full bounded channel blocks the sender (backpressure, section 5) and a dead peer gives ProcessDown, so a send never needs a “buffer full” variant.)

Multiple returns: tuple, error-set, or Result

Section titled “Multiple returns: tuple, error-set, or Result”

Success can carry more than one value, and there are three return forms, one per combination of “is there value(s)?” × “is there error?”, without overlap:

  • Only value(s), no error a tuple (a, b): a group of values between parentheses, separated by comma. The spelling is the same in the signature, in the Ok and in the return. (Parentheses are the group-of-values token throughout the language, whether in arguments, arithmetic grouping or a tuple; the comma distinguishes a tuple from simple grouping. The braces {} are left for bodies, literals and sets of variants, including error-sets.)
  • Only error, no value the error-set alone (-> error{...}, above): success is the absence of error, no payload.
  • Value(s) + error always Result[V, E], with V being the tuple when there is more than one value.
fn split(s: string) -> (string, string) // values, no error → tuple
fn send(...) -> error{ProcessDown} // success without value → error-set
fn divmod(a: int, b: int) -> Result[(int, int), error{DivZero}] // values + error → Result

In the Ok, the tuple destructures leftright into the variables (Ok((q, r))), and the return matches by position (return (q, r)).

Error and value do not coexist as a positional list, and that is why the form (u8, error) does not exist. Result is exclusive: Err means there is no value. Mixing value and error in a positional return would suggest “u8 and error together”, which is precisely what does not happen, since in the Err there is no u8. The boundary is hard: as soon as an error enters, the form is Result. There exclusivity is explicit (Ok or Err, never both) and the “this can fail” stays prominent in the type, not buried in a slot. A tuple is value only; value(s)+error is Result; only-error is the error-set alone. There are three forms, without overlap and without desugaring, and this follows from explicitness: where it can fail, the Result shouts; it does not become a slot hidden in the middle of values.

For external errors (FFI, third-party libraries) where you control neither the source nor know the set, use an open error (without the {...} that closes it into a set): non-exhaustive, requires _:

fn call_external() -> Result[Data, error] // open error: unknown set