A Novel Look at Error Handling in Rust | Jared Jacobson’s Programming Blog
There has been a lot said about error handling in rust. There are many different opinions about how to structure your errors, when to use panics, and the role of proc-macros in error handling. Most of the discussion around rust error handling makes the assumption that when an error occurs you either pass the error up to the caller or you try to completely recover and continue on. While this describes many situations, it fails to address all of them. I want to take a different look at error handling and how it can be approached. This is not meant to replace the mainstream miasma of error propogation, it is merely meant to add a new tool in your toolbelt.
An Overview of the Status Quo
If we have a function that calls function that returns Result there a few different ways this could be handled in rust. To illustrate this, lets say we have a function that takes 2 strings, parses them into u32s and adds them together. Here are a couple of different ways we could handle failing to parse the string.
It may just panic. When this is the case, it is quite common to explain this in the documentation for the function.
/// Parse the 2 strings into a `u32`s and add them<br>///<br>/// # Panics<br>///<br>/// Panics if the provided string can not be parsed<br>fn add_strs_panic(s1: &str, s2: &str) -> u32 {<br>let u1 = s1.parse().expect("s1 should be a valid `u32`");<br>let u2 = s2.parse().expect("s2 should be a valid `u32`");<br>u1 + u2
It may return an Option. This approaches will often utilize the combinators provided by the standard library like Option::ok in conjuction with the question mark operator.
/// Parse the 2 strings into `u32`s and add the results<br>///<br>/// returns `None` if parsing fails<br>fn add_strs_option(s1: &str, s2: &str) -> Optionu32> {<br>let u1 = s1.parse().ok()?;<br>let u2 = s2.parse().ok()?;<br>Some(u1 + u2)
It may return an result type. This often involves wrapping the error up in some way.
enum AddStrsError {<br>// variants that wrap it nicely (skipped for brevity)<br>/// parse the 2 strings into `u32`s and add the results<br>///<br>/// return an error if parsing fails<br>fn add_strs_result(s1: &str, s2: &str) -> Resultu32, AddStrsError> {<br>let u1 = s1.parse().map_err(/* provide more context for the error */)?;<br>let u2 = s2.parse().map_err(/* provide more context for the error */)?;<br>Ok(u1 + u2)
There is even a nice way to handle not failing at all and instead recovering from the failure. A function treat bad inputs as 0 or some other random value.
(As an aside, I think the ability to easily and cleanly recover from errors is one of the main benifits of “errors as values”.)
/// parse the 2 strings into `u32`s and add the results<br>///<br>/// if parsing fails, defaults the value to 0<br>fn add_strs_default(s1: &str, s2: &str) -> u32 {<br>// we could use `unwrap_or_default` but I think 0 is more clear here<br>let u1 = s1.parse().unwrap_or(0);<br>let u2 = s2.parse().unwrap_or(0);<br>u1 + u2
The Problem
The last example is somewhat contrived and probably wouldn’t make sense for that example, but I have written code that looks somewhat like that code. “Defaulting” code has a problem: the caller of the function has no way of knowing if it was done. It totally reasonable for a function to recover from an error and continue doing work. It is also reasonable for the caller of a function to want to know that something inside the function failed. The problem is the lack of a solution for when both of these things happen together.
This problem, failing but also continuing, is somewhat common. A few (perhaps more realistic) examples of this include:
Collecting all errors that occur during a specific stage of a compiler.
Using a default file path if the file path doesn’t exist
Treating a zero in the denominator of a division operator as evaluating to 01
Running all of the tests in a test suite even if some of them fail 2
Solution 1: The Go Approach
I think it is quite interesting that the way the go programming language handles errors doesn’t have this problem. For those who aren’t familiar I’ll provide an extremely brief overview.
Go, like rust, uses “errors as values”. In go, an error is any type that implements the Error interface. Unlike rust, Go doesn’t really have sum types. So instead of using sum types, a function that can fail returns a tuple with the happy path value and an error type like this:
func AddStrsDefault(s1 string, s2 string) (uint64, error) {<br>var err error<br>i1, err := strconv.ParseUint(s1, 10, 32)<br>// note: if both functions return an error, only the second error will be seen<br>i2, err := strconv.ParseUint(s2, 10, 32)
return i1 + i2, err
Because any pointer in Go can be nil, if there isn’t an error, the returned value is set to nil.
This could be a viable option when we encounter the problem in rust. If our computation will always complete but there may be an error as well we could return an optional error too:
fn add_strs_default(s1: &str, s2: &str) -> (u32,...