Scoped Error in Rust

I’ve never been fully satisfied with any error handling crate in Rust. I’ve tried many and even developed a few helpers. Here are the key issues I 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 adding .with_context() everywhere. It’s verbose and repetitive. Error reporting requires knowing how anyhow::Error handles format strings. Error propagation lacks location information; the alternative is backtrace, which pulls in heavy std dependencies.

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

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

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

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

Example:

use anyhow::Result;

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

fn complex_method() -> Result<()> {
  let cfg = read_config().context("validate config file")?;
  parse(cfg).context("parse config file")?;
  Ok(())
}

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 it to all error branches. snafu works similarly.

Example:

use exn::{Result, ResultExt};
use thiserror::Error;

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

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

fn complex_method() -> Result<(), MyError> {
  let err = || MyError("complex method");
  let cfg = read_config().or_raise(err)?;
  parse(cfg).or_raise(err)?;
  Ok(())
}

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 to the module-scoped Error type. But the repeated .or_raise(err)? gets annoying fast.

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

Example:

use scoped_error::{Error, expect_error};

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

fn complex_method() -> Result<(), Error> {
  expect_error("failed to do complex thing", || {
    let cfg = read_config()?;
    parse(cfg)?;
    Ok(())
  })
}

The core idea is simple: attach context exactly once. Not at every call site, not at every failure point, just between caller and logic. I want per-module Error types without manual conversion at every step.

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

The result: a clean, readable declaration of fallible operations. A default Error type is provided, but any std Error implementing WithContext 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 error to Box<dyn Error> and captures file location via #[track_caller] for a lightweight stack trace.

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

Error: failed to do complex thing, at src/main.rs:12:19
|-- read config file, at src/main.rs:5:19
`-- No such file or directory (os error 2)

The scoped-error crate also packs a few extras: a macro_rules! macro for creating common errors that implement WithContext, and a Many error type for multi-cause errors.

The crate is on crates.io, source is on Codeberg, and docs cover the details. If you try it, I’d love to hear your use cases. File an issue or drop me a note. I’m using this in my own projects now and finally Rust error handling feels right.

1

The core of the library is really tiny.

// Copyright (C) 2026 Kan-Ru Chen <kanru@kanru.info>
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

use std::any::Any;
use std::borrow::Cow;
use std::error::Error as StdError;
use std::fmt::{Debug, Display};
use std::panic::Location;

/// A trait for error types that can carry context information.
pub trait WithContext: StdError + Any {
    /// Attach a context layer to this error.
    fn with_context(self, context: Frame) -> Self;

    /// Get the location where this error was created or where context
    /// was attached.
    fn location(&self) -> Option<&'static Location<'static>>;
}

/// A single layer of error context.
pub struct Frame {
    /// The underlying error that caused this context.
    pub source: Box<dyn StdError + Send + Sync + 'static>,
    /// The location where this context was attached.
    pub location: &'static Location<'static>,
}

impl<T> From<T> for Frame
where
    T: Into<Box<dyn StdError + Send + Sync + 'static>>,
{
    /// Creates a `Frame` from any error type, capturing the
    /// caller's location.
    #[track_caller]
    fn from(value: T) -> Self {
        let source = value.into();
        let location = Location::caller();
        Frame { source, location }
    }
}

/// Low-level function for adding context with a custom error constructor.
#[inline(always)]
pub fn expect_error_fn<F, T, E>(
  err: F,
  body: impl FnOnce() -> Result<T, Frame>,
) -> Result<T, E>
where
    F: FnOnce() -> E,
    E: WithContext,
{
    body().map_err(|context| err().with_context(context))
}

/// Add context to errors, returning a custom error type.
#[inline(always)]
pub fn expect_error<T, E>(
    msg: impl Into<Cow<'static, str>>,
    body: impl FnOnce() -> Result<T, Frame>,
) -> Result<T, E>
where
    E: From<(Cow<'static, str>, Frame)>,
{
    body().map_err(|context| (msg.into(), context).into())
}

留言討論

本站使用 Pinka,歡迎使用你喜歡的聯邦網路訂閱與留言。