Skip to content

Idiomatic error propagation #411

@nwalfield

Description

@nwalfield

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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions