thiserror icon indicating copy to clipboard operation
thiserror copied to clipboard

Idiomatic error propagation

Open nwalfield opened this issue 1 year ago • 1 comments

Currently, we have a number of crates where we have a top-level Error enum, but our functions return anyhow::Error instead of the enum. It's been pointed out that this is not idiomatic and it would be better to just return Error and use thiserror's transparent forwarding. I'm confused about how to idiomatically handle matching on forwarded error variants. Consider the following crate structure:

            high-level
       /                       \
low-level-a   low-level-b

where high-level defines Error, low-level-a defines ErrorA and low-level-b defines ErrorB. If each of the enums includes an IoError(std::io::Error) variant, how is code supposed to match on the std::io::Error? To make the question concrete, consider this code, which is what I currently imagine:

#[derive(thiserror::Error, Debug)]
enum Error {
    #[error("something bad happened: {0}")]
    Bad(String),

    #[error(transparent)]
    IoError(#[from] std::io::Error),
    #[error(transparent)]
    A(ErrorA),
    #[error(transparent)]
    B(ErrorB),
}

#[derive(thiserror::Error, Debug)]
enum ErrorA {
    #[error(transparent)]
    IoError(#[from] std::io::Error),
}

#[derive(thiserror::Error, Debug)]
enum ErrorB {
    #[error(transparent)]
    IoError(#[from] std::io::Error),
}

fn f() -> Result<(), Error> {
    Err(Error::Bad("ouch".into()))
}

fn main() {
    match f() {
        Ok(()) => {

        }
        Err(Error::IoError(err))
        | Err(Error::A(ErrorA::IoError(err)))
        | Err(Error::B(ErrorB::IoError(err))) => {
            // Handle the io error.
            eprintln!("io error: {}", err);
        }
        Err(err) => {
            eprintln!("An error occured: {}", err);
        }
    }
}

This is how we currently do it:

#[derive(thiserror::Error, Debug)]
enum Error {
    #[error("something bad happened: {0}")]
    Bad(String),
}

#[derive(thiserror::Error, Debug)]
enum ErrorA {
}

#[derive(thiserror::Error, Debug)]
enum ErrorB {
}

fn f() -> anyhow::Result<()> {
    Err(std::io::Error::new(std::io::ErrorKind::Other, "oh no!").into())
}

fn main() {
    let result = f();
    match result {
        Err(err) => {
            if let Some(err) = err.downcast_ref::<std::io::Error>() {
                // Handle the io error.
                eprintln!("io error: {}", err);
            } else {
                eprintln!("Not an io error: {}", err);
            }
        }
        Ok(()) => {
            eprintln!("Everything is fine")
        }
    }
}

That is, we downcast to std::io::Error and it doesn't matter if the std::io::Error comes from high-level, low-level-a, low-level-b, or another crate that high-level starts using later: the user of the high-level API can reliably and compactly catch std::io::Errors.

Thanks for any insights!

nwalfield avatar Jan 16 '25 16:01 nwalfield

This seems intentional, so that the API represents "where" this io::Error was returned as part of the high-level Error or a nested ErrorA or ErrorB, as a sort of "stack trace".

Normally I would predict that having a source error, i.e. #[source] or #[from] would allow you to recursively walk down std::error::Error::source() and downcast to std::io::Error to find it anywhere in an error chain. However, I just learned today that #[error(transparent)] also plainly forwards that function call a level down, i.e. changing your f() to return:

fn f() -> Result<(), Error> {
    Err(ErrorB::IoError(std::io::Error::other("io failure!")).into())
}

And calling .source() on Error won't give you ErrorB (where .source() can be recursively called again), but will instead call .source() directly on ErrorB which will (because of error(transparent) on IoError()) call .source() on std::io::Error, which doesn't have a nested error, and returns None instead.

Replacing at least the last error(transparent) could solve this:

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=c4aebd122e64ef66c6ed863f2e5fd414

MarijnS95 avatar Dec 11 '25 13:12 MarijnS95