Skip to content

← Errors Are Values step 19 of 24

Hard Framework

Adding context without anyhow

Rebuild anyhow’s .context() in about fifteen lines, then use it.

pub trait WithContext<T> {
    fn context(self, msg: &str) -> Result<T, ConfigError>;
}

pub fn load(pairs: Vec<(String, String)>, keys: Vec<String>) -> Result<Vec<i32>, String>

load reads each requested key out of pairs, parses it as i32, and returns the values in order. On the first failure it returns the fully rendered cause chain as a string. Two failure shapes:

key `retries` is missing
key `port` is invalid: invalid digit found in string

The first has one link, the second two: your context, then std’s own ParseIntError message, joined by ": ".

You write two things — the blanket impl of WithContext, and a render helper that walks the chain. Everything else is given.

Why context is the thing beginners omit

invalid digit found in string is a true statement and a useless bug report. It does not say which key, which file, or which row. The information that makes it actionable is not available where the error is createdstd has never heard of your config file — it is available where the error is caught.

Context belongs at the call site, not in the error type’s Display.

That is the design rule for this whole item. ConfigError::Context has a msg field precisely because the message is decided by whoever called .context(…), not baked into a variant.

The extension trait

impl<T, E: Error + 'static> WithContext<T> for Result<T, E> {
    fn context(self, msg: &str) -> Result<T, ConfigError> {
        self.map_err(|e| ConfigError::Context {
            msg: msg.to_string(),
            source: Box::new(e),
        })
    }
}

That is the trick in full. A trait with one method, blanket-implemented for every Result whose error implements Error, so .context("…") becomes available on results you did not write — parse(), read_to_string(), anything. It is a map_err with a fixed shape and a nicer name, and it is almost exactly what anyhow::Context is.

The bound the starter gets wrong

error[E0310]: the parameter type `E` may not live long enough
   |
   |             source: Box::new(e),
   |                     ^^^^^^^^^^^ ...so that the type `E` will meet its
   |                                 required lifetime bounds
help: consider adding an explicit lifetime bound
   |
   | impl<T, E: Error + 'static> WithContext<T> for Result<T, E> {
   |                  +++++++++

Box<dyn Error + 'static> erases the type, and erasing requires knowing the value contains no borrowed data — otherwise the box could outlive what it points into. E: Error alone permits E = SomeError<'a>, so the compiler stops you. The 'static here bounds the type, not a reference: “this type holds nothing borrowed”, which every sane error type satisfies.

This is also, not coincidentally, the same bound downcast_ref needs.

The other two errors this shape produces

  • E0119 if you write a second overlapping impl — say a blanket one and a specific one for Result<T, ParseIntError>. Rust has no specialisation.
  • E0210, the orphan rule: you may only implement a trait for a type if the trait or the type is yours. Here WithContext is yours, so implementing it for the foreign Result is fine.

Be careful about what that last point teaches you. In this single-file harness every type is local, which makes the orphan rule feel like it never bites. In a real crate, impl SomeoneElsesTrait for SomeoneElsesType is rejected — which is exactly why the extension-trait pattern exists: you cannot add a method to Result, so you declare your own trait and implement that.

Rendering the chain

Same walk as the previous item:

fn render(e: &dyn Error) -> String {
    let mut parts = vec![e.to_string()];
    let mut cur = e.source();
    while let Some(s) = cur {
        parts.push(s.to_string());
        cur = s.source();
    }
    parts.join(": ")
}

Note again that ConfigError‘s own Display prints only msg — no colon, no cause. If it printed the cause too, render would emit it twice. Display describes one link; the chain is assembled by the caller.

Notes

  • map_err_ignore is the lint that objects to discarding a source. Here you are doing the opposite — keeping it — which is what makes the two-link message possible.
  • needless_pass_by_value would suggest &[(String, String)] over Vec<(String, String)> for a parameter you only read. The signature here is fixed by the harness; in your own code, take the slice.
  • result_large_err fires when an error type is big enough to bloat every Result in the program. A Box keeps ConfigError small — one more reason wrapping variants box their source.
  • The first failure short-circuits: once one key fails, later keys are never read.

Remember the grade is compile + tests + clippy -D warnings.