I just published erra, a small crate that fills a specific gap in Rust error handling.
The problem it solves: ? propagates errors faithfully but strips all call-site context. The usual fix is anyhow::Context, which works well in applications but erases the error type. Once an error enters anyhow::Error, the only structured recovery path is a runtime downcast, which the compiler cannot verify.
erra lets you annotate any Result with a string at the call site while keeping E fully typed:
rust
use erra::ResultExt;
fn load_config(path: &str) -> Result<String, erra::Error<std::io::Error>> {
std::fs::read_to_string(path).annotate("reading application config")
}
The annotated error is still an io::Error inside. Callers can pattern match on it at compile time, no downcast needed.
A few design properties that guided it:
-
Zero dependencies, including no proc-macro
-
no_std compatible; bare-metal targets with no allocator can use the static string path
-
From<E> is deliberately not implemented, so ? cannot silently wrap an error with no context
-
On the Ok path, .annotate() compiles to a zero-cost identity in release builds
It is not a replacement for anyhow in application code, and it is not a replacement for thiserror at public API boundaries. It is for the call sites in between, where you want context without giving up the type.
Docs: docs.rs/erra
Source: github.com/ZaudRehman/erra
Feedback welcome, especially if you run into cases where the API does not compose well with something in your codebase.
It's an interesting choice to me to display as {context}: {source} while also supplying source directly in the Error trait impl.
Comparing to thiserror, it's kind of like a mix of transparent and adding a message prefix.
Also interesting, if you have nested function calls all returning this erra::Error<E>, would it display as
cause 1: cause 2: cause 3: source?
And in anyhow or eyre, would it show the call chain:
cause 1: cause 2: cause 3: source
Caused by:
- cause 2: cause 3: source
- cause 3: source
- source
- ....
Yes, that is the intended behavior.
erra is not transparent in the thiserror sense. Its Display implementation deliberately includes the context string, so the error is readable on its own in logs and quick debugging. At the same time, source() still returns the wrapped error, so structured error reporters can traverse the chain normally.
For nested errors, the Display output is recursive. In a small test, this printed:
outer: loading config: middle: reading file: entity not found
And the source() chain was preserved as well:
depth 0: middle: reading file: entity not found
depth 1: entity not found
So yes, the mental model of cause 1: cause 2: cause 3: source is correct for Display. The chain is also available separately through source(), which is what anyhow or eyre can walk.
The design is intentionally closer to “prefix plus source chain” than to transparent. That is how erra keeps the call-site context visible without losing the underlying error type.
Can this nesting not be made transparent?
pub type Result<T, E> = core::result::Result<T, erra::Error<E>>;
Yes, absolutely. That is a standard Rust pattern and it works cleanly with erra. You can define a module-level Result alias to hide the wrapper:
pub type Result<T, E> = core::result::Result<T, erra::Error<E>>;
Then your function signatures become:
use erra::ResultExt;
fn load_config(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path).annotate("reading application config")
}
The erra::Error<E> is still there underneath, but the signature reads cleanly. This is entirely up to each consuming crate since erra deliberately does not ship a Result alias of its own, to avoid imposing naming conventions on dependents.
The customary way is to provide erra::Result, but not include it in your prelude. Then it’s easy to fetch, but doesn’t get in the way, where not desired. I didn’t check, whether you offer a prelude, but such distinctions are a good reason to.
Good point. erra::Result as the canonical alias makes sense, and keeping it out of the prelude preserves flexibility. We’ll think it through and make sure it’s included in the next release.
I apologize if I'm mistaken, but it seems like your posts are LLM generated (specifically, the phraseology "That is a standard Rust pattern and it works cleanly with..." is one I've seen LLMs use frequently). While we can't control how you write your code, it is against the site's TOS to use AI-generated replies. Even if your English isn't so great, we would prefer you hand-wrote your posts instead of delegating to an AI.
Yeah, fair catch. I used it to help draft the reply because I wanted to make sure it sounded right, but I totally understand the rule. I'll stick to handwriting my posts from now on.
Just a quick update, v0.2.0 is now live on crates.io.
I went ahead with the suggestions mentioned here and added a canonical erra::Result<T, E> type alias to clean up the function signatures. Like we talked about, I kept it out of the prelude to preserve flexibility, but I did add a small prelude module if you just want to glob-import ResultExt.
Here’s how a signature looks now with the new alias:
use erra::{Result, ResultExt};
fn process() -> Result<i32, std::io::Error> {
std::fs::read_to_string("id.txt")
.annotate("failed to read id source")?;
Ok(42)
}
I also fixed a couple of transitive dependency issues in CI (the half crate bumped its MSRV to 1.81 recently) that were breaking the 1.75.0 builds on older toolchains.
Thanks again for the feedback on the first release, it really helped shape this update!