Skip to content

Errors are values, not handled events

Why there is no ErrorHandler in this crate

The Go sibling of this toolkit, go-tool-base, threads an ErrorHandler through every command. Each handler returns its error the idiomatic Cobra way, and a single wrapper at the root routes all of them through one object that decides how an error is presented, what hint is attached, and what the process does next. One door out, one place where presentation is decided.

That component exists because Go needs it. A Go error is an interface value with a string in it. Attaching a machine-readable identity, a separate line of human advice, and a rendering strategy means building somewhere to keep them and somewhere to apply them.

Rust already has that somewhere: the error type itself. A type deriving miette::Diagnostic carries its own code, its own help text, its own severity, its own source span and its own related diagnostics. There is nothing left for a handler to attach, because the attachment happened where the error was defined. Building an ErrorHandler in Rust would mean taking information that already travels with the value and moving it into a side channel.

So the funnel is not missing. It is replaced by three things that were already in the language and the ecosystem: the ? operator does the propagation, the Diagnostic derive does the enrichment, and one installed hook does the rendering.

What "errors are values" means in practice

An error in a tool built on this crate is an ordinary value returned by an ordinary function. It is not thrown, not intercepted, not registered anywhere. Three consequences follow, and they are the whole design:

It propagates with ? and nothing else. No wrapper call at each level, no .check(), no chance of a handler being forgotten on one branch. A function that cannot deal with an error returns it, and the ? is the entire ceremony.

It is rendered exactly once, at the edge. Every intermediate layer stays silent. This is why the crate offers no logging of its own and why the footer lives on the render path rather than on the error: by the time anything is printed, the error has already finished travelling.

Its presentation is decided by one installed hook. That is the only global in the design, and it is miette's global, not this crate's. Swapping it changes every diagnostic in the process at once, which is exactly what "one place where presentation is decided" was supposed to buy.

Why the umbrella Error enum is small

rtb_error::Error has five variants and four of them describe application scaffolding failing — a config source rejecting a value, a command name that does not exist, a Cargo feature that was compiled out, and I/O. It is not trying to be the error type for your program.

That is deliberate. An umbrella enum that grows a variant per subsystem becomes a central file every crate has to edit, and every consumer has to match against variants that will never occur in their code path. Instead, each crate defines its own #[derive(Error, Diagnostic)] enum with codes in its own namespace, and the umbrella carries one transparent variant — Other — for the moments when a typed diagnostic has to cross into framework code.

Other renders transparently precisely so this indirection costs the user nothing: the boxed error's code and help are what appear on screen, and the wrapper never announces itself.

Why #[non_exhaustive] is on the enum

Adding a variant to a public enum is a breaking change in Rust: somebody's exhaustive match stops compiling. #[non_exhaustive] trades that away by requiring a wildcard arm from the start, so a new variant can ship in a minor release.

The cost is real — you cannot get compiler help proving you handled every case — and it is accepted because a framework error enum is exactly the kind of type that grows. There is a compile-fail test fixture in the repository whose only purpose is to fail if that requirement is ever relaxed.

Where this reasoning came from

The design predates this crate: it was settled while porting go-tool-base to Rust, and the trade-offs are argued at length in Errors without an error handler. That post is the history. This page is the position, and the position is what the code implements.