A few weeks ago, I created a post on the /r/rust subreddit about Rust's error system and its shortcomings compared to corresponding systems other languages, like Java. To summarize it, in Rust, the standard way to handle errors is to either use a crate like Anyhow to simply bubble up any errors to the caller (and hopefully, eventually, to some code that is in a position to handle it) via the ?
operator, or to create a complex (compared to Java, see below) custom error type for each module or crate. This error type usually takes the form of an enum with a variant for each kind of error that can take place from that crate or module, or a thin wrapper around that.
The problem with these approaches is that the user of your functions likely doesn't know which errors, exactly, can happen from each function. With Anyhow, this problem is even worse since a function returning the Anyhow error type can return literally any error . This may be acceptable in some situations, but it clearly isn't ideal. When you create a custom error type, it sometimes isn't a trivial undertaking, which is likely why most crates don't have an error type for each function, which would clearly document how each function can fail. Compare this system of poorly-defined ways that a function can error to how it's done in well-written Java (Java does a lot of things poorly, but it does errors well) pseudo-code:
public Object readAndParseFile(Path file) throws IOException, ParseError{
String contents = Files.read(file); //throws IOException
Object parsedContents = parseString(contents); //throws ParseError
return parsedContents;
}
In this example, the set of possible failure modes is clearly documented in the function's signature (RuntimeException
can be used to bypass this issue in Java, but this is usually bad practice). Furthermore, it is standard for Javadoc comments to describe under which scenarios each exception is thrown.
In response to this post, many useful objections were raised, such as that treating errors as data has advantages (e.g. threading is easier, and in many parts of the code the error can be handled as data, such as with Vec<Result<…>>
). From this, I created a new crate, polyerror, that combines the best of both worlds to create what I hope will make Rust code cleaner, simpler, and better documented.
Polyerror works by making it so trivial (literally a one-line macro call) to define an ergonomic (? works) and correct error type that it is practical to have a separate error type for each function. This way, it is always obvious to the end user in which way a function can error:
use std::str::ParseBoolError;
use std::num::ParseIntError;
create_error!(pub ParseThenCombineError: ParseBoolError, ParseIntError);
pub fn parse_then_combine(a: &str, b: &str) -> Result<String, ParseThenCombineError> {
let parsed_bool: bool = a.parse()?;
let parsed_int: i32 = b.parse()?;
Ok(format!("{} {}", parsed_bool, parsed_int))
}
In this toy function, two errors are possible: ParseBoolError and ParseIntError (both from the standard library). With the traditional model, one would either use something like Anyhow and obscure useful (if not vital) information from the users of your crate, forcing them to delve into your code or hope that it's documented properly (this reminds me of how lifetimes are specified in C and C++), or simply add these two error types to a global error enum. Here, instead, the ParseThenCombineError
(you're free to choose less verbose names if that's your style) is to be used only for the parse_then_combine
function. Since it's defined with a single line of code before the function, this technique isn't any significant tedium or productivity drain.
To give a precise idea of what's going on, the above create_error!
call expands to this (slightly reformatted) source code:
#[derive(Debug)]
pub enum ParseThenCombineError {
ParseBoolError(ParseBoolError),
ParseIntError(ParseIntError),
}
impl ::std::fmt::Display for ParseThenCombineError {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl ::std::error::Error for ParseThenCombineError {}
impl ::std::convert::From<ParseBoolError> for ParseThenCombineError {
fn from(error: ParseBoolError) -> Self {
Self::ParseBoolError(error)
}
}
impl ::std::convert::From<ParseIntError> for ParseThenCombineError {
fn from(error: ParseIntError) -> Self {
Self::ParseIntError(error)
}
}
Just one macro is exported that provides all that you need for robust, easily understandable (for you and your users), and correct error handling in Rust. The generated error type is documented in Rustdoc pages just like any other type, so it's easy for users to quickly understand what's going on, even if they've never heard of this crate before — it is entirely transparent to a user of your API. With this crate, I hope to make Rust code safer, cleaner, simpler, and better documented.