Skip to content

Rationale · essay 05

Errors

rationale.md · 80 lines · 3 min read

The redefinition: an error is a value, not a control event. Result[T,E] + error-sets, explicit propagation (no ?), and no unit.

There are two big schools for failure. Exceptions (Java/Python/C++): the error is an event that “explodes” and rises up the stack until someone catches it, invisible in the function’s type. Error-as-value (Go/Rust): the failure is a return value that you inspect. Makoto stands firmly in the second school, and radicalizes the explicitness.

Failure has two questions, and there is a construct for each:

  • Result[T, E] is the resolution: Ok(T) or Err(E). It is a family of arity 2, like Map[K,V], so it composes with generics (map over the Ok, collect of List[Result[T]] to Result[List[T]]). Whoever receives a Result cannot ignore the failure: it is in the type.
  • error{...} is the union: which failures exist. Closed (error{A, B}, exhaustive, forbids _), open (bare error, requires _), or empty (error{} = “does not err”, Err unconstructible).

Propagation is explicit, by trailer, never by operator: v := do() catch |e| { return e } propagates; catch |e| treats the error as a unit; match |e| branches by variant in one shot (it binds and discriminates, without the double-binding “first catch, then match”). And success-without-value does not use unit: it is the error-set alone (fn send(m) -> error{...}), where success = absence of error. noreturn is the bottom type of divergence (panic/exit), which makes an error arm “escape” without giving a value.

The declared inspiration is Go (forcing the recovery tree to be drawn line by line), but with a twist: instead of repeating if err != nil, you write catch/match |e| once, and the compiler demands that you handle or propagate on purpose. The central gain is killing the hidden flow: you never propagate an error by accident; each step is a visible decision (“handle here or pass it on?”).

The choice against unit is pure conceptual economy: do not multiply empty types. If success carries no value, the error-set alone already says everything; if you need success as a composition value, Result[(...), E] solves it, but the common case (a procedure that can fail) does not pay for a phantom type.

  • Exceptions. Hidden control flow: you call fetch_user(id) and it can eject NetworkError/ParseError/Timeout to any level of the stack, with nothing in the type. The failure contract stays invisible. Error-as-value makes it part of the signature.
  • The ? operator (Rust/Zig). It is the same danger as uncontainable coloring, applied to errors: it becomes the way to escape handling the failure, a glyph that propagates by itself. The language should make the cost of propagation visible; catch |e| { return e } is longer on purpose, and it is that friction that kills negligence. (Note the parallel with unsafe: the difference is that unsafe is containable and ? only hides.)
  • unit/void as a type. An empty type that carries no information; the bare error-set takes its place without the phantom.
  • -> (T, error) raw Go style. It allows the ambiguous nil, nil and does not compose with generics. Makoto pushes toward the canonical form: -> (u8, error) generates a “use Result[u8, error]” warning.

The function in Java whose throws lies (or does not exist), and the NullPointerException that rises from six layers below. The unwrap()/? in Rust that becomes a habit and swallows failures in code that should be robust. The repeated if err != nil of Go that is annoying but (it is acknowledged) works, because it forces you to look at each failure. Makoto wants the Go effect (mandatory attention to each failure) without the repetition and without the ? escape door.

An error is a value that you handle or propagate, explicitly. Failure is an exit of the program, not an event that the runtime resolves for you.

Verbosity is the price, and it is deliberate: you do not chain do_a()?.do_b()?.do_c()?; you assemble the recovery tree step by step. The spread of error-sets (error{open..., parse...}) adds a bit of machinery, but it is what avoids re-enumerating the whole list of variants of each subsystem. The bet: each line that propagates costing to be written is what guarantees that no failure is forgotten.

Next: 06 · Absence