TL;DR: go-tool-base routes every error through one ErrorHandler so presentation is consistent. rust-tool-base has no error handler at all. main() returns a miette::Result, every crate’s error type derives a Diagnostic, and a framework-installed hook renders whatever comes back with its code, severity, help text and source labels. The single consistent exit that go-tool-base built by hand is, in Rust, the language’s return-from-main convention plus one hook.
What go-tool-base built
A while ago I wrote about error handling in go-tool-base. The core of it: an error should carry a hint, a separate field of human guidance telling the user what to do next, kept apart from the error’s identity so code can still match on it.
The other half of that post was about consistency. Every go-tool-base command returns its errors the idiomatic Cobra way, and they all funnel into one Execute() wrapper at the root, which routes every error through one ErrorHandler. One door out. Presentation decided in exactly one place, so no command can render a failure differently from its neighbour.
That handler is a real object. It exists, it’s wired in, it’s the thing every error passes through. Building it was a deliberate piece of work, and it was the right call for Go.
When I rebuilt this in Rust, the handler didn’t survive the move. Not because consistency stopped mattering. Because Rust gives you the single exit for free, and an object to enforce it would just be re-implementing something the language already does.
The shape of a Rust error
Start with the type. In rust-tool-base every crate defines its own error enum, and every one of them derives two traits:
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum ConfigError {
#[error("config file not found at {path}")]
#[diagnostic(
code(rtb::config::not_found),
help("run `mytool init` to create one, or set MYTOOL_CONFIG"),
)]
NotFound { path: PathBuf },
// ...
}
thiserror::Error makes it a proper error type. miette::Diagnostic is the interesting one. A Diagnostic is an error that also carries the things you’d want when presenting it: a stable code, a severity, a help string, and optionally source labels pointing at spans of input. The help line is the same idea as go-tool-base’s hint, the recovery step, except here it’s an attribute on the variant rather than a field threaded through a wrapper.
So the guidance lives on the error, structured, from the moment the error is created.
There is no handler, there’s a convention
Here’s where Rust does the work go-tool-base’s handler was built to do.
A rust-tool-base main looks like this:
#[tokio::main]
async fn main() -> miette::Result<()> {
rtb::cli::Application::builder()
.metadata(/* ... */)
.version(VersionInfo::from_env())
.build()?
.run()
.await
}
main returns miette::Result<()>. Every command’s run returns a Result too. In between, errors propagate with the ? operator: a function that hits an error returns it upward, immediately, and the caller does the same, all the way to main. Nobody writes a “check this error” call. ? is the propagation.
And when an error reaches main and main returns it, something has to render it for the user. That something is a report hook. rust-tool-base installs one at startup, and from then on any Diagnostic that exits main is rendered through it: the code, the severity, the help text, the source labels, with colour. One renderer, installed once.
Look at what that adds up to. Every error in the program flows to one place, main. It is rendered by one thing, the hook. Presentation is decided in exactly one location and no command can deviate from it. That is precisely the property go-tool-base’s ErrorHandler was built to guarantee. The difference is that nobody built it. The single exit is just where ? propagation ends, and the single renderer is one hook. The language’s own convention for returning errors from main is the funnel.
Errors are values, all the way
The thing that took me a moment to fully trust is that there’s no funnel to maintain, because there’s no funnel as an object. go-tool-base’s handler is a component: it can drift, it has to be kept in the path, a command could in principle be wired to bypass it. The Rust version cannot be bypassed, because bypassing it would mean a command not returning its error, and an error you don’t return is a compile-time warning at best and dead-obvious wrong code at worst.
So the model is just: errors are values, you return them, ? carries them up, main hands the last one to the hook. The consistency isn’t enforced by a guard. It’s the only thing the shape of the language lets you do.
go-tool-base reaches a single, consistent error exit by building one and routing everything through it. rust-tool-base reaches the same exit by having errors be ordinary return values and letting them fall out of main. Same outcome. One of them is a component you own; the other is a convention you inherit.
Worth remembering
go-tool-base funnels every error through one ErrorHandler so presentation stays consistent. That handler is a deliberately built component, and it’s the right design in Go.
rust-tool-base has no handler. Every crate’s error type derives miette::Diagnostic, carrying its code, severity and help text. Errors propagate with ? to main, which returns miette::Result, and a framework-installed hook renders whatever comes out. The single consistent exit is the end of ? propagation, and the single renderer is one hook. The funnel go-tool-base built by hand is, in Rust, just the language’s return-from-main convention.