The Result
type in rust is great! Combined with the ?
operator, it makes error handling much more concise than other languages. Consider ?
vs:
- Go:
if err != nil { return nil, err }
- Java:
try { ... } catch { ... }
&throws
When defining my own Result<T, E>
type, leaving the T
generic is really great, so you can have functions that return a variety of things, and have them return the same kind of error without having to specify it each time.
However, deciding what to use for the E
was a litte more involved. I laid out below what three approaches for what to use as E
with pros and cons of each along with what they look like, and frequently downloaded crates that use that approach.
- Option 1: Re-use an existing
Result
type - Option 2a: Wrapping - Use an
enum
forE
withmap_err
- Option 2b: Wrapping - Use an
enum
forE
withFrom
impl’s - Option 3: Boxing - Use
Box<dyn error::Error>
I ended up going with option 2b – an enum
with From
impl’s.
Option 1: Re-use an existing Result
type
The first option is to not define a new Result
type and just use an existing one. For example, in a CLI, I found I was running a lot of functions that returned std::io::Result
. I could use that result type directly and bypass the E
decision altogether. (Another similar approach is to define a single error type of our own and return that everywhere).
use std::io;
fn get_string() -> io::Result<String> {
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(buffer)
}
fn main() {
get_string().unwrap();
}
(playground , Rust By Example)
Pros:
- No
E
decision - Pretty common type
Cons:
- Really falls apart when you branch out from
io
operations - what happens when I have an application specific error that’s not related to
io
? - what about errors from other packages (like
std::env
)?
Example Users
Option 2a: Wrapping - Use an enum
for E
The next option is to define my own Result
type, and use an enum type for our E
. (Fun fact: Result is actually just an enum
of Ok(T), Err(E)!). I can specify an enum variant for each of the existing error types I know I’ll see, and I’m free to add new enum variants specific to my application. I can use map_err
to convert from the original error type to my enum.
use std::{io, env, result};
use std::fs::File;
#[derive(Debug)] <--- so we can unwrap()
enum CliError {
Io(io::Error),
EnvVarError(env::VarError)
}
type Result<T> = result::Result<T, CliError>;
fn get_string() -> Result<String> {
File::open("foo.txt").map_err(|e| CliError::Io(e))?;
std::env::var("PATH").map_err(|e| CliError::EnvVarError(e))?;
Ok(String::from("Success!"))
}
fn main() {
get_string().unwrap();
}
Pros
- Can handle any new error type
- Is explicit about the allowable errors
Cons
- Need to convert errors a bunch with
.map_err(|e| CliError::VarErr(e)
.map_err(|e| CliError::Io(e)
- Need to add a new variant for each new error type
Example users:
Option 2b: Wrapping - Use an enum
and implement the From
trait on it
Those map_err
calls can get a little annoying to write over and over. Fortunately, there’s an option to implement the From
trait on my enum in order for the compiler to essentially do the map_err
calls for me. Here’s what that looks like:
use std::{io, env, result};
use std::fs::File;
#[derive(Debug)] <--- so we can unwrap()
enum CliError {
Io(io::Error),
EnvVarError(env::VarError)
}
impl From<env::VarError> for CliError {
fn from(error:env::VarError) -> Self {
CliError::EnvVarError(error)
}
}
impl From<std::io::Error> for CliError {
fn from(error: std::io::Error) -> Self {
CliError::Io(error)
}
}
type Result<T> = result::Result<T, CliError>;
fn get_string() -> Result<String> {
File::open("foo.txt")?;
std::env::var("PATH")?;
Ok(String::from("Success!"))
}
fn main() {
get_string().unwrap();
}
ProsLets me use ?
without map_err
most places
ConsNeeds a separate impl
block for each new error type, which can be a little verbose
Example Users
Option 3: Boxing - Use Box<dyn error::Error>
A final option that needs way less code but is still flexible to error types is to use Box<dyn error::Error>
. This is an approach listed in the “rust-by-example” book as “Box-ing errors”. My initial reaction was: “wat”. I had seen a Box
before (as a way to move something from the stack to the heap), but I hadn’t seen dyn
before. It uses dynamic dispatch to call the functions associated with the std:error::Error
trait on the heap allocated error object.
Here’s what it looks like for our example:
use std::{result, error};
use std::fs::File;
type Result<T> = result::Result<T, Box<dyn error::Error>>;
fn get_string() -> Result<String> {
File::open("foo.txt")?;
std::env::var("PATH")?;
Ok(String::from("Success!"))
}
fn main() {
get_string().unwrap();
}
Pros
- No more impl blocks or enum variants for each new error type, so it’s much more concise
- I don’t even need to know what error types I’m encountering ahead of time (especially useful if I’m exposing generic functions to other libraries)
Cons
- uses dynamic dispatch (slight performance cost)
- puts things on the heap that could be in the stack (slight memory cost)
- Needs calls to
into
in more places for your own error types (since they’ll be in the stack, and need to be Boxed)
Example users:
Conclusion
In my case, I decided to go with Option 2b. It had the right tradeoffs of being explicit so I could be more deliberate about new error scenarios as I introduced them (and I wasn’t writing a library). I also was dealing with a few error types very frequently (mosty io::Error
), so adding a handful of impl
blocks was a small one-time cost.
I hope this was helpful! If I’ve misstated anything or there are other options you think are worth considering, please do let me know.
Other interesting discussions
type Result<T>
vs. type Result<T, E = My Error>
“In the docs, […] type aliases are orangeish and enums are green” – Since Result
is an enum, you can tell if someone is using the base type (green) or their own type alias (orange) when you search for Result.