Scoped Error in Rust

fanf21 pts0 comments

Scoped Error in Rust

Scoped Error in Rust

西元2026年05月22日 - Kan-Ru Chen

The Inspiration

Enter Scoped Error

I’ve never been fully satisfied with any error handling crate in Rust. I’ve<br>tried many and even developed a few helpers. Here are the key issues I<br>found with each. Theses are issues my scoped-error crate tries to address.

The Inspiration

anyhow - good for a drop-in Error type that just works, but requires<br>adding .with_context() everywhere. It’s verbose and repetitive. Error<br>reporting requires knowing how anyhow::Error handles format strings. Error<br>propagation lacks location information; the alternative is backtrace,<br>which pulls in heavy std dependencies.

thiserror - good for defining custom Error types. The #[from] implementation<br>encourages a single Error type that encompasses all possible sources. But<br>the ergonomics stop there. Using these types is still tedious if you want<br>per-module errors with good context. The improvement over manually rolling<br>Error types seems small compared to the syn and compile-time overhead.

snafu - combines manual context attachment with anyhow and thiserror<br>patterns in one crate. However, I feel like I’m encoding all my error<br>branches into Snafu contexts. Those implementation details don’t need<br>to be public, yet snafu tightly couples the Error type to them. Maybe<br>I’m using it wrong.

exn - a refreshing approach to error handling. I<br>actually started my crate based on the pattern from<br>the blog post Stop Forwarding Errors, Start Designing<br>Them.<br>The minor issues with exn 0.3 are: (1) you still need to remember<br>.or_raise(err) for each fallible operation, and it’s easy to miss<br>for intra-module method calls; (2) the Exn wrapper itself is not a<br>std Error, so interop with other error types requires adapters like<br>exn-anyhow or exn-stderr.

While switching between these error crates, I kept noticing a gap: with<br>anyhow-like crates, you attach context at each call site, but the method<br>itself lacks it.

Example:

use anyhow::Result;

fn read_config() -> ResultString> {<br>let raw = std::fs::read_to_string("config.toml")?;<br>Ok(raw)

fn complex_method() -> Result()> {<br>let cfg = read_config().context("validate config file")?;<br>parse(cfg).context("parse config file")?;<br>Ok(())<br>It’s easy to ? away the context when all your methods return anyhow::Result.

With exn-like crates, you define error context for each method and attach<br>it to all error branches. snafu works similarly.

Example:

use exn::{Result, ResultExt};<br>use thiserror::Error;

#[derive(Debug, Error)]<br>#[error("MyError: {0}")]<br>struct MyError(&'static str);

fn read_config() -> ResultString, MyError> {<br>let err = || MyError("read config file");<br>let raw = std::fs::read_to_string("config.toml").or_raise(err)?;<br>Ok(raw)

fn complex_method() -> Result(), MyError> {<br>let err = || MyError("complex method");<br>let cfg = read_config().or_raise(err)?;<br>parse(cfg).or_raise(err)?;<br>Ok(())<br>The common issue: it’s easy to ? away the context when all methods share the same Result type.

Enter Scoped Error

I really liked exn’s approach: define an error closure to force conversion<br>to the module-scoped Error type. But the repeated .or_raise(err)? gets<br>annoying fast.

I started creating wrappers to mediate conversion from source errors to<br>module-scoped Errors. I soon realized this pattern solves several ergonomic<br>issues with other approaches, and checks the boxes I cared about.

Example:

use scoped_error::{Error, expect_error};

fn read_config() -> ResultString, Error> {<br>expect_error("read config file", || {<br>let raw = std::fs::read_to_string("config.toml")?;<br>Ok(raw)<br>})

fn complex_method() -> Result(), Error> {<br>expect_error("failed to do complex thing", || {<br>let cfg = read_config()?;<br>parse(cfg)?;<br>Ok(())<br>})<br>The core idea is simple: attach context exactly once. Not at every call<br>site, not at every failure point, just between caller and logic. I<br>want per-module Error types without manual conversion at every step.

expect_error() has three responsibilities: prepare context for future<br>errors, force inner errors into a boxed type to type-erase the inner error, and<br>wrap the outer error with the inner as its source.

The result: a clean, readable declaration of fallible operations. A<br>default Error type is provided, but any std Error implementing WithContext<br>works too.

The core library is tiny. It’s small enough to vendor directly into your project1.

The inner boxed error type Frame takes its name from exn. It converts any<br>error to Box and captures file location via #[track_caller]<br>for a lightweight stack trace.

With the built-in Error type or the ErrorExt::report() helper, error<br>trees (yes, trees are supported) render like this:

Error: failed to do complex thing, at src/main.rs:12:19<br>|-- read config file, at src/main.rs:5:19<br>`-- No such file or directory (os error 2)<br>The scoped-error crate also<br>packs a few extras: a macro_rules! macro for creating common errors that<br>implement WithContext, and a Many error type for multi-cause errors.

The crate is on...

error type context scoped errors result

Related Articles