What is the idiomatic way to transitively propagate ? through nested error enums?
It's easier to give an example than explain the question:
fn do_stuff() -> Result<(), Error> {
let x = fallible()?;
let y = fallible2()?;
Ok(())
}
fn fallible() -> Result<i32, ReadError> {
Err(ReadError::FileNotFound("file1.txt".to_string()))
}
fn fallible2() -> Result<i32, NetworkError> {
Err(NetworkError())
}
#[derive(From)] //or use thiserror
enum Error {
Network(NetworkError),
Io(IoError),
}
#[derive(From)] //or use thiserror
enum IoError {
Read(ReadError),
Write(WriteError),
}
enum ReadError {
FileNotFound(String),
}
struct NetworkError();
This code won't work because ? could map ReadError into IoError and it could map IoError into Error, but it can't map ReadError into Error, so line 2 fails.
I feel like these kinds of nested errors tend be a natural way to write errors and that it's also natural to want to bubble up to the top level error. I understand why transitive intos don't make a lot of sense generically, but in this case, where there is only 1 "direction" (You only ever move towards the more general error), I imagine it makes more sense. In my head I would want to be able to handle this situation with something like a ??.
In any case, seeing as there is no (to my knowledge) to handle this as is. What are the best practices for these kinds of situations?
I can think of the following approaches:
Implement From<ReadError> for Error. In a real-world situation with many nested sub-errors, I could see this getting unwieldy kind of quickly
Use map_err() every time. I guess this would work, but it feels kind of verbose and lacking in cleanliness
What approach is most idiomatic? Is there a better way to handle this situation?
You have already identified the two solutions I would consider. In the case where it comes up a lot, correctly, I would impl From<ReadError> for Error.
But, in my experience, there is often some additional information that should be provided; it is important not just that error types make sense, but that the error when finally printed has enough information to troubleshoot (or report) the problem. In this perspective, one should be suspicious of error enum variants that just mean “one of that kind of error”, and not “one of those errors, which was encountered during…”. When you do that, often you will end up having to use .map_err() (or something in the spirit of anyhow’s .context(), if you like) anyway, to add that information — or end up with a different, perhaps simpler structure.
For example, consider a version of do_stuff() that's more concrete about what kind of thing it's doing:
fn do_stuff() -> Result<(), DoStuffError> {
let x = read_config()?;
let y = contact_server(x)?;
Ok(())
}
fn read_config() -> Result<i32, ReadError> { ... }
fn contact_server(_: i32) -> Result<i32, NetworkError> { ... }
#[derive(thiserror::Error)]
enum DoStuffError {
#[error("failed to read config file")]
ReadConfig(#[from] ReadError),
#[error("failed to contact server")]
Server(#[from] NetworkError),
}
Now, in this particular example, because DoStuffError is more specific about what situations it applies to, and explains what is going on the user, there is no longer a transitive From problem, because it contains only ReadError and not the more general IoError. (Avoid having variants that can’t happen!) In another case — where one of the variants has a field besides the nested error — I might instead say that yes, there should be map_err() calls inside do_stuff().
Make error types that convey useful messages. Have the code do what it must to produce those messages.
If you do use map_err, note that you only need to convert to the type that can then be converted by ? to the return type. And you can abbreviate the closure with the function path.
let x = fallible().map_err(IoError::from)?;
For me this occurs infrequently enough that I don't mind using map_err.
Imagining, for example, that an env. variable has to be read both somewhere near the top of the call stack (say in lib.rs or a fn called by main), and also as part of read_config().
Would this be a case where it's fine to have this error type repeated, since DoStuffError::ReadConfig(ReadError::Env(...)) is technically not same as DoStuffError::Env(...)? Or is there a better approach to take here?
Also, regarding creating precise errors that are specific to context -- surely creating a new error type for every function that can fail in unique ways would be excessive (particularly if many of those failure modes are shared between a bunch of functions). Any rules of thumb or best practices for scoping out at what level error or what frequency it makes sense to have error enums?
To be a little more concrete, I'm currently building out a DB service, where there is a global error my_app::Error and a more specific my_app::DB::Error. In this case, I'm guessing I'd want all of the fallible functions in my DB to return Result<_, my_app::DB::Error>, and we might expect that a call to, say, db.get_obj(id)? would likely occur in a function that returns Result<_, my_app::Error>? Does this sound about right?
Yes, you shouldn't claim it's a ReadError if it wasn't related to reading. But more importantly, VarError doesn't specify what environment variable wasn't available, which is necessary information for the user to fix the problem. Once you add that information, you may find it's not so repeated.
Certainly there are cases where it makes sense to not be perfectly precise. But note that what you started with is itself a certain sort of precision: nesting many error enums to create sub-categories. The really “imprecise” side would be having a single, flat enum Error, or dyn Error for everything — and that eliminates the transitive From too.
In the big picture, the thing I am advocating for is that you should design your error types so that they usefully communicate to the user or caller. What is useful is context-dependent — just avoid assuming that wrapping all inner error types — not error sites, but error types — in a new enum is a good solution. Sometimes it is too fine, and sometimes it is too coarse. Design your error types in service of the final result, not the error types that you started with.