Skip to content

Idiomatic error propagation #411

Open

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!

Activity

  1. MarijnS95 commented on Dec 11, 2025

    @MarijnS95

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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