HomeArticles

Rust: Enums to wrap multiple errors

Rust

This is a follow-up to Error handling in Rust from a couple of days ago. The moment we want to use error propagation for different error types, we have to rely on trait objects with Box<dyn Error>, which means we defer a lot of information from compile-time to runtime, for the sake of convenient error handling.

Which you might consider not convenient at all, because there’s some downcasting involved to get the original error back, and we rely on trait objects and dynamic dispatch to carry something like an error along our codebase. I’d rather have this information erased at compile time!

Memory layout of Box and Box<dyn Trait>
Memory layout of Box and Box<dyn Trait>

There is a really nice pattern to handle multiple errors that involve enums. This is what I want to share with you today. It requires setting up a lot more boilerplate (which surely can be macro’d somehow), but in the end, I find it much better to use, and it arguably has some benefits at runtime as well.

Previously: Trait objects #

Let’s quickly recap what we ended up with in the last example.

use std::error;

fn number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
/* 1: std::io::Error */
let mut file = File::open(filename)?;

let mut buffer = String::new();

/* 1: std::io::Error */
file.read_to_string(&mut buffer)?;

/* 2: ParseIntError */
let parsed: u64 = buffer.trim().parse()?;

Ok(parsed)
}

This function can cause two different error types.

  1. An std::io::Error when we open the file or read from it
  2. A std::num::ParseIntError when we try to parse the string into a u64

Since both implement the std::error::Error trait, we can use a boxed trait object Box<dyn Error> to propagate the error and have a dynamic result based on what happens in our program. Again: It’s important to iterate that this defines dynamic behavior at runtime, whereas in all other cases Rust tries to figure out as much as possible at compile.

Using enums #

Instead of having a dynamic return result, we prepare an Error enum with all possible errors. In our example, that’s a ParseIntError as well as an std::io::Error.

enum NumFromFileErr {
ParseError(ParseIntError),
IoError(std::io::Error),
}

To use this enum as an error, we need to implement the std:error::Error trait for it. As we know from the last article, the Error trait itself doesn’t need any extra implementation, but we need to implement Debug and Display.

Debug is easy to derive…

#[derive(Debug)]
enum NumFromFileErr {
ParseError(ParseIntError),
IoError(std::io::Error),
}

And Display is mainly writing the error messages of each of our errors into a formatter.

impl Display for NumFromFileErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NumFromFileErr::ParseError(parse_int_error) =>
write!(f, "{}", parse_int_error),
NumFromFileErr::IoError(io_error) =>
write!(f, "{}", io_error),
}
}
}

// Make it an error!
impl std::error::Error for NumFromFileErr {}

You can already sense the repetition coming. If our function might return a third error type, the NumFromFileErr enum, as well as the Display implementation, need adaption.

What about propagation? #

With that, we can already use our custom error in a Result<T, E>. If we change it (like in the following example on the first line), we get a couple of errors, though.

fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
let mut file = File::open(filename)?; // Error!

let mut buffer = String::new();

file.read_to_string(&mut buffer)?; // Error

let parsed: u64 = buffer.trim().parse()?; // Error

Ok(parsed)
}

What’s happening? The three methods in read_number_from_file still cause std::io::Error and std::num::ParseIntError. When we propagate them using the question mark operator ?, they are not compatible to NumFromFileErr. The Rust compiler tells us exactly what’s wrong (this one is to scroll):

error[E0277]: `?` couldn't convert the error to `NumFromFileErr`
--> src/main.rs:34:40
|
33 | fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
| --------------------------- expected `NumFromFileErr` because of this
34 | let mut file = File::open(filename)?;
| ^ the trait `From` is not implemented for `NumFromFileErr`

Let’s focus on the first line. The question mark operator couldn’t convert the error to NumberFromFileError. So let’s do that on our own. Match each error, if the operation was successful, return the value, if not, return with an Error from NumFromFileError

fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
let mut file = match File::open(filename) {
Ok(file) => file,
Err(err) => return Err(NumFromFileErr::IoError(err)), // 👀
};

let mut buffer = String::new();

match file.read_to_string(&mut buffer) {
Ok(_) => {}
Err(err) => return Err(NumFromFileErr::IoError(err)), // 👀
};

let parsed: u64 = match buffer.trim().parse() {
Ok(parsed) => parsed,
Err(err) => return Err(NumFromFileErr::ParseError(err)), // 👀
};

Ok(parsed)
}

Wow, that’s tedious! What happened to our sweet propagation? Well, the errors are incompatible, so we have to make them compatible. But there’s a better way to it. One that’s more idiomatic and is hinted at in the second part of the error message. the trait From<std::io::Error> is not implemented for NumFromFileErr

The From trait #

The From trait allows you to define how to go from one type to another. It’s a generic trait, where you specify which type you want to convert, and then implement it for your own types. Since we already defined how to treat ParseIntError and std::io::Error in the enum itself, the conversion implementations are pretty straightforward.

impl From<ParseIntError> for NumFromFileErr {
fn from(err: ParseIntError) -> Self {
NumFromFileErr::ParseError(err)
}
}

impl From<std::io::Error> for NumFromFileErr {
fn from(err: std::io::Error) -> Self {
NumFromFileErr::IoError(err)
}
}

Oh… can you smell the beauty of repetition? There is another way of converting one type into the other, by implementing the Into trait. If you need to implement the conversion, always go for From. The reverse Into trait comes along for free, due to this beauty in Rust’s core library:

impl<T, U> Into<U> for T
where
U: From<T>,
{
fn into(self) -> U {
U::from(self)
}
}

This implements conversion of Into for generic T, where we want to convert T into U. If U implements From<T> as defined by the trait boundary, we just call the respective from method. It’s beauties like these that make Rust such an elegant language and shows the true power of traits.

And that’s pretty much it. With the conversion to go from the two errors into our custom-defined one, error propagation works again!

fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
let mut file = File::open(filename)?;

let mut buffer = String::new();

file.read_to_string(&mut buffer)?;

let parsed: u64 = buffer.trim().parse()?;

Ok(parsed)
}

Sweet! A bit of extra boilerplate, but no trait objects. Nothing on the heap. No vtable for dynamic lookup. Much less runtime code. And some extra benefits…

Matchin enum branches vs downcasting #

One thing that really bugged me is downcasting from a trait object to a real struct. To me, this feels a lot like working with hot coals, because you never know which errors can actually occur. I think it’s guesswork if it’s not well documented. This here:

fn main() {
match read_number_from_file("number.txt") {
Ok(v) => println!("Your number is {}", v),
Err(err) => {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
eprintln!("Error during IO! {}", io_err)
} else if let Some(pars_err) = err.downcast_ref::<ParseFloatError>() {
eprintln!("Error during parsing {}", pars_err)
}
}
};
}

perfectly compiles, even though my function never returns an error result of ParseFloatError. If we use enums, tooling and the enum itself tells us which possible errors are available. Also, working with those errors becomes very elegant again:

fn main() {
match read_number_from_file("number.txt") {
Ok(v) => println!("Your number is {}", v),
Err(err) => match err {
NumFromFileErr::IoError(_) => println!("Error from IO!"),
NumFromFileErr::ParseError(_) => println!("Error from Parsing!"),
},
};
}

This is also one of the beautiful things about Rust. It’s a language that allows you to go from a very low-level to a very high-level programming style without sacrificing elegance!

Repetition #

The only thing we sacrifice in comparison to Box<dyn Error>is the amount of boilerplate we need to create. Trait objects are just so convenient, aren’t they? But with everything that looks like repetition and boilerplate, also looks like we could have macros helping us with code generation. And with Rust, you can be pretty sure somebody already did that.

One crate that I found is thiserror, which helps you avoid repetition and allows for very complex custom error cases.

It might also be a fun exercise to create something like that on my own!

Bottom line #

Boxed trait objects have their purpose and are a really good way for handling cases that are only known at runtime. Box<dyn Error> is also something that looks like it’s very common. However, even though the enum version creates a lot more code, it feels also a lot less complicated to me. Enums are much simpler to handle than trait objects. How they affect memory is known at compile time. And an enum tells me exactly what my options are.

Whenever I run into functions that can propagate various errors, Enums as errors is my go-to way of handling them.

There’s also the perspective from David Tolnay, who created both thiserror and anyhow: Use thiserror if you care about designing your own dedicated error type(s) so that the caller receives exactly the information that you choose in the event of failure. This most often applies to library-like code. Use Anyhow if you don’t care what error type your functions return, you just want it to be easy. This is common in application-like code.

And, as always, there’s a link to the playground.

Related Articles