Handling Errors in Rust with Traits
Rust is a bit peculiar (in a good way) when it comes to many things, errors is one of them. So to add to the growing body of texts written on the topic of programming in rust, I have written this.
So Errors, as everything else in rust, consist of types and traits. An error is actually the Err
variant of the generic std::result::Result
enum. This enum variant may hold an arbitrary type, but this normally holds an "error" type.
An Error type is a type that is specifically constructed to represent an error condition.
Other than the programmers intent, an error struct is no different from any other struct out there.
Error types often end up being simple unit-like structs, allowing an error condition to explicitly exist inside the type system.
Having an error be a particular type inside rusts type system avoids the need to encode Errors as output values, as is often the case in C.
Errors being types also allows them to implement various traits that make them useful for things like reporting errors to a user (by implementing the Display
trait).
Even though Error types often are unit-like structs or simple enums, they are in no way limited to those types.
Errors are just normal types and thus may store data on the stack and heap, enabling many different ways of reporting and resolving errors (possibly atomatically).
So in essence errors are a variant of an enum
, they may be returned from functions and processed using a match
statement. The Err
variant of the Result
enum may contain a type
that is specifically constructed to represent the different error states of a program/subsystem.
In the following example we will run through a fairly typical use of errors, those that come up when parsing user input. We will see an evolution from an explicit example to something that will integrate with the many useful features rust offers to handle results. For this we define a new unit-like struct to represent the errors in parsing the input.
#[derive(Debug)]
struct ParseInputError;
So now we have a type that represents the an input parsing error.
The thing that we want to end up with after parsing is the following struct.
#[derive(Debug, PartialEq)]
enum Input {
I(i32),
F(f32),
Exit,
}
In our made-up case, we'd prefer to get an integer, but we can live with a float. We also want the user to be able to exit the program so we want the input to be able to represent the exit command. With these three things we have all the possible states of our input.
Now when an error occurs, it would be nice for us to tell the user, what happened.
This is normally done with the println!
or the print!
macro in rust.
These macros can interpret format strings and insert variables from the local context into them.
For this to work however, the variable type needs to implement the std::fmt::Display
trait, which we shal implement for our error type.
impl fmt::Display for ParseInputError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Input was unable to be parsed into either a number or an exit command")
}
}
Now with this in place, we can display the error message to our user, assuming that we encountered an error that is stored as the err
variable.
println!("Sorry we encountered an error: {err}")
This is just to show how useful it is to integrate custom types into the existing std::
type system, to enable many cool built in features of the rust language.
We will now implement another trait that enables the .parse()
method on a string to parse into our Input
enum.
This trait method will inevitably need to return an instance of the generic Result
type.
As I said earlier, in our example we'd prefer to get and integer from our input, but we'd also be fine with a float.
At the same time the user needs to be able to quit the program, which should be accomplished by typing exit
into the command line.
It is possible to parse a string into an integer by using the .parse()
method that becomes available when the std::str::FromStr
trait is implemented for a type.
impl FromStr for Input {
type Err = ParseInputError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.starts_with("exit") {
Ok(Input::Exit)
} else {
match s.trim().parse::<i32>() {
Ok(num) => Ok(Input::I(num)),
Err(_) =>
match s.trim().parse::<f32>() {
Ok(num) => Ok(Input::F(num)),
Err(_) => Err(ParseInputError),
},
}
}
}
}
With the above trait being implemented for the Input
type, it we can now directly run the parse
method on the user input.
As can be seen, inside the method we run through a few steps to produce our Input
struct from the string.
First of all, we check if the user entered "exit" as the first part of the input.
If that was the case, we return the Input::Exit
enum variant.
This is a fairly straight forward way to do it because in that sense, we are not really "parsing" in the full sense of the word.
If the input does not start with exit, we procede with a first match that matches on the result of the s.trim().parse::<i32>()
.
We need to match both variants of the Result
that is returned.
In case of the Ok(num)
variant, we simply return the Input::I(num)
variant of our input enum.
In case of the integer parsing failing, it is attempted to parse a float value from the string.
We again match on both the Ok
and Err
variants of the Result
, returning the Input::F(num)
in case of success or our input parsing error in case of a failure.
It is interesting to note, that the data returned was wrapped in the different variants of the Result
enum even though we are extracting the data from various Results.
This is because the match
statement unpacks the content of the Result, and as the from_str
function needs to return a Result
of it's own it is again wrapped in one.
Using the methods from Result
One can imagine that for a rather simple case like this one, the amount of code is still pretty much managable, but for larger projects with many possible states both for the proper result or for the error, this code can get large quick.
So the first thing we can do is to make use of the various functions implemented by the Result type to handle transforming Err
and Ok
results, reducing the amount of matching code that needs to be written.
In this particular instance we shal use the map_or_else
to apply one function to an Ok
result and a different one to an Error
.
We repeat this twice once for the parse::<i32>()
and nested inside the Err
function a second time with parse::<f32>()
.
//...
s.trim().parse::<i32>()
.map_or_else(
|_| s.trim().parse::<f32>().map_or_else(
|_| Err(ParseInputError),
|num| Ok(Input::F(num))),
|num| Ok(Input::I(num))
)
//...
Now it turns out, that the second code block is actually 16 lines of code vs 15 lines for the match heavy version. As it stands now we have written more lines than before. However this formulation allows us to use some more trait magic to reduce the number of lines in this (and of course any other) error handling situation referring to the Input
type.
Now we can implement the From
trait for our Input
enum, so that we can convert to it from an i32
and from an f32
.
If the editor is set up right, this implementation can also be autogenerated by the rust-analyzer
.
impl From<f32> for Input {
fn from(v: f32) -> Self {
Self::F(v)
}
}
impl From<i32> for Input {
fn from(v: i32) -> Self {
Self::I(v)
}
}
These traits now allow to reduce some of the code in the closures.
s.trim().parse::<i32>()
.map_or_else(
|_| s.trim().parse::<f32>().map_or_else(
|_| Err(ParseInputError),
|num| Ok(num.into())),
|num| Ok(num.into()),
)
The thing is, that when the parsing of the f32
fails, then the outcome will be a ParseInputError
.
This means we can also implement the std::convert::From
trait for the ParseInputError
so that we can then convert the error that is raised when the parsing of the float fails into the ParseInputError
, making our life a little easier.
This implemetation is in our case also pretty trivial.
impl From<ParseFloatError> for ParseInputError {
fn from(_value: ParseFloatError) -> Self {
ParseInputError
}
}
now that all these traits are implemented, we can now use the ?
operator in rust to shorten our code somewhat.
Now that we can covert from a ParseFloatError
to a ParseInputError
we can get rid of the map_or_else
and replace it with a simple map
and let the ?
handle the rest.
The ?
does the following: As the compiler can deduce at compile time, that the thing that needs to be returned from the function in which the ?
can be found is an Err(ParseInputError)
and the compiler knows how to convert from the ParseFloatError
which is the result of the parsing operation in case of an error, to a ParseInputError
, it injects the code for the conversion and wraps all this into the correct Result
type.
All this with a single ?
. This finally leaves us with:
s.trim().parse::<i32>()
.map_or_else(
|_| s.trim().parse::<f32>().map(|num| Ok(num.into()))?,
|num| Ok(num.into()),
)
which is what I'd say is some pretty readable error handling if you ask me.
There is however a small caveat in this code that took some experimenting to find.
In contrast to the map_or_else
method, which return the result of the functions directly, the map
applys it's function and then wraps the output of that function back in an Ok(...)
.
So if we read the above snippet carefully the map
function after the float parsing actually returns Ok(Ok(Input::F(num)))
.
We however directly apply the ?
operator to the output of the map
and return that immediately.
The ?
operator unwraps the Ok
, returning the inner value, or returns directly when encountering an Err
without unwraping it first.
This is why we need to nest, the outer Ok
is for the ?
to unwrap and the inner Ok
is used so that we return a Result<Input, ParseInputError>
instead of returning just the Input
.
To illustrate this, we can also write the previous code as:
s.trim().parse::<i32>()
.map_or_else(
|_| Ok(s.trim().parse::<f32>().map(|num| num.into())?),
|num| Ok(num.into()),
)
which wraps the output of the ?
operator in an Ok
instead of nesting it.
To reduce the amounts of 'unnecessary' Ok
wrappings that we have to spell out in code we can also write the above section as:
s.trim().parse::<i32>()
.map(|v| v.into())
.or_else(|_| Ok(s.trim().parse::<f32>().map(|num| num.into())?))
reducing all of the error handling to just two lines.
As this function itself returns a Result, all we need to do if we want to let a possible Err
bubble up to where it can be properly addressed is again applying the ?
operator.
// we assume that the text that is to be parsed is in text_in
let input: Input = text_in.parse()?;
making our code nice and concise.
Combining many errors
Now more often than not, we may have multiple sources for an error while processing input.
In the above example only covers the parsing of the input.
This means that we have not properly handled the case where an error occures when reading from stdin
.
As we can only return one type of Result
from a function (remember Result is a generic type
, we need to somehow combine the std::io::Error
with our ParseInputError
.
We can do this by extending our ParseInputError
to become an InputError
.
This InputError
would of course need two variants:
ParsingError
, which is essentially theParseInputError
from beforeIOError
which encapsulates thestd::io::Error
s that can appear.
Our InputError
thus becomes an enum.
#[derive(Debug)]
enum InputError {
ParseError,
IOError(std::io::Error),
}
Now we need to add a std::convert::From
trait to InputError
to be able to convert to it from a std::io::Error
, which can also be done with the help of code automation thanks to the rust-analyzer
.
impl From<std::io::Error> for InputError {
fn from(v: std::io::Error) -> Self {
Self::IOError(v)
}
}
We also need to expand the implementation of the Display
trait to handle the new error variant.
impl fmt::Display for InputError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ParseError => write!(f, "Input was unable to be parsed into either a number or an exit command"),
Self::IOError(err) => write!(f, "Encountered error in IO system: {err}")
}
}
}
With this modest changes, we can now write a small main function that uses all this fancy error handling, as we can now return a single Result
type from main()
.
fn main() -> Result<(), InputError> {
let mut guess = String::new();
loop {
std::io::stdin().read_line(&mut guess)?;
let input = guess.parse::<Input>();
match input {
Ok(ip) => {
match ip {
Input::I(num) => println!("Input parsed to integer: {}", num),
Input::F(num) => println!("Input parsed to float: {}", num),
Input::Exit => return Ok(()),
}
}
Err(err) => println!("Error {err} encountered, please try again"),
}
guess.clear();
}
}
Here, like before, a match
statement is used to unwrap the nested data structures.
As the output of the .parse
method is a Result
first the result needs to be unwrapped, before we can handle the different variants of the Input
.
This means nested match
statements, which I personally don't really like.
Rust offers some mechanism for avoiding nested matches
.
Besides using the methods of the Result
type to handle the outer Result
, Rust offers a mechanism to easily process code only when a data structure matches a particular pattern.
This structure is if let <pattern> = ...
.
The Idea is the following, take care of the Result
with the map_err
method, displaying the error message. Then the if let
construct is used to run the processing of the input only when the result of the parsing matches with Ok(Input)
.
With these changes, the netsted match
statement becomes:
if let Ok(input) = guess.parse::<Input>().map_err(|err| {println!("Error: {err}, please try again"); Err(err)}) {
match input {
Input::I(num) => println!("Input parsed to integer: {}", num),
Input::F(num) => println!("Input parsed to float: {}", num),
Input::Exit => return Ok(()),
}
}
reducing the amount of nesting as desired.
Conclusion
As can be seen, Errors are handeled using types and Rusts elaborate system of traits to allow custom Errors to be integrated with rusts native syntax. Option
and Result
are the main two types involved when handling Errors and integrating them by implmenting some few traits, enables error handling that is quite elegant in my oppinion.
Gone are the days, of looking up what integer values represent what error conditions and possibly forgetting to handle them results in difficult to find bugs or security vulnerabilities.
The only price we pay for this, is having to read up on some of the language workings, something I hope I could help with.
Cheers.