Why I Prefer Rust Error Handling Over Go
A large part of software development is handling errors. Generally speaking, we cannot predict and account for everything that is input to the program, but it is important to handle issues gracefully as the algorithm encounters them. Like it or not, as engineers it is our job to provide a good user experience and that includes error handling.
This post describes my experiences with error handling in two programming languages: Go and Rust. We compare a simple example of reading the contents of a file.
Go
Let us start by exploring error handling in Go. One of the biggest
advantages of Go is that it aims to keep everything simple. As such,
there is not much to error handling. Go provides an error
interface,
so any type that implements it can be returned as an error
.
func getFileContents() ([]byte, error) {
file, err := os.Open("file.txt")
if err != nil {
return nil, err
}
defer file.Close()
b, err := ioutil.ReadAll(file)
return b, err
}
If you are familiar with Go, you will know that this error checking boilerplate is everywhere.
if err != nil {
return nil, err
}
That is because majority of the time, error handling means “pass the
error back up to the caller.” There is nothing wrong with that, and
I suspect it is the reason why languages like to use the try
catch
paradigm, but that is why it is everywhere in Go.
Another reason it is everywhere is because it works everywhere.
The power of this error handling model comes from its simplicity.
You can create your own types that implement the error
interface
and handle it just like any other error.
Errors are Everywhere
Let’s face it, errors are everywhere. If you imagine any non-trivial
program, its function call tree will almost always return an error.
As we established, majority of error handling is passing the error
to the parent (also known as bubbling up), which means every function
needs to return an error
. At the root is where all of these errors need
to be addressed. This is the point that I do not like about Go error handling.
It is at this point that you have an error
interface that can be
any concrete type. You could simply print the error using the
Error()
method as defined by the interface, but I think that hides
a lot of important information for the parent to properly handle
the error for different cases. Going back to our file reading
example, let us consider two reasons for an error: the file does not
exist, and the file is not readable.
func main() {
_, err := getFileContents()
if err != nil {
switch err := err.(type) {
case *os.PathError:
switch err := err.Unwrap().(type) {
case syscall.Errno:
syscallNum := uintptr(err)
if syscallNum == 2 {
// file does not exist
fmt.Println("file does not exist")
} else if syscallNum == 13 {
// permission denied
fmt.Println("permission denied")
} else {
// unhandled error
fmt.Println("unhandled error")
}
}
}
return
}
fmt.Println("Success!")
}
Before you start writing this example off as bad code, there is
reason I chose to demonstrate this abomination. It shows just how
terrible and messy it is to handle different error types. There
will almost always be an unknown case that you cannot handle, and
many times libraries will return an errors.errorsString
type which
you cannot detect the type of because it is un-exported!
Now, maybe I am doing something wrong here, and if so, please correct me! Before I get a flood of emails, let me provide the proper way to check for file existence and permission errors.
func main() {
_, err := getFileContents()
if err != nil {
if os.IsNotExist(err) {
// file does not exist
fmt.Println("file does not exist")
} else if os.IsPermission(err) {
// permission denied
fmt.Println("permission denied")
} else {
// unhandled error
fmt.Println("unhandled error")
}
return
}
fmt.Println("Success!")
}
Better, but not by much if you ask me. Let’s take a look at the Rust side. Spoiler alert: I like it better.
Rust
One of the biggest advantages of Rust is its compiler. It is by far the best compiler I have ever worked with. It points you to exactly the part of the code that is wrong, provides clear explanations of the error, and even gives you tips on how to fix it. Most importantly for this post, the compiler knows how to do things for you (more on this later). Let’s jump into an example.
fn get_file_contents() -> io::Result<Vec<u8>> {
let mut file = File::open("file.txt")?;
let mut contents = vec![];
file.read_to_end(&mut contents)?;
Ok(contents)
}
On first reading, this looks a lot more complicated than the Go
version. What is io::Result<Vec<u8>>
and why is there a ?
at
the end of some lines? Those familiar with Rust can probably answer
these questions better than me (I am a new Rustacean), and it is
not the focus of this post. However, I will provide a short summary:
Result
types hold one of two types: an Ok
or an Err
type. The
?
operator essentially says: “if it is an Err
, return from the
function, otherwise extract the data from Ok
.”
By comparison to the Go version, there is no error checking boilerplate
in the Rust version. Instead, it is replaced with ?
. As with Go, this
bubbles up the error to be handled by the parent caller. Let’s see how
that looks.
fn main() {
let result = get_file_contents();
if let Err(x) = result {
match x.kind() {
ErrorKind::NotFound => println!("not found"),
ErrorKind::PermissionDenied => println!("permission denied"),
_ => println!("unhandled error"),
};
return;
};
println!("success!");
}
I think this is on-par with the non-abominable Go version. One bonus
that Rust offers is ensuring you handle all enum cases (with _
being the catch-all). I will also note that this is handling the
specific io::Error
type. This is contrasted to Go
by handling
the generic error
interface.
Errors are Everywhere (especially in Rust)
Imagining our function tree again, there are a lot of places where
we would want to bubble up errors, but they will not always be io::Error
types! What is the point of ?
if it only works for errors of the same type?
Well, it turns out ?
will automatically convert from one error type to another,
you just need to tell the compiler how to do that. Let me say that again because
the first time I learned it, it was a complete revelation. The Rust compiler will
automatically translate errors if it knows how to!
Alright, let’s do it.
First let’s change the return type to Result<Vec<u8>, MyError>
so we can return our custom error type.
fn get_file_contents() -> Result<Vec<u8>, MyError> {
// snip
}
Now let’s define MyError
and how to convert io::Error
to it. Let’s
just store the IO error in an enum.
enum MyError {
IOError(io::Error),
}
impl From<io::Error> for MyError {
fn from(err: io::Error) -> MyError {
MyError::IOError(err)
}
}
Finally, let’s update the error handling in main.
fn main() {
let result = get_file_contents();
if let Err(x) = result {
match x {
MyError::IOError(x) => {
match x.kind() {
ErrorKind::NotFound => println!("not found"),
ErrorKind::PermissionDenied => println!("permission denied"),
_ => println!("unknown error"),
};
}
};
return;
};
println!("success!");
}
Note that we now need to match x
because it is a MyError
enum. Also note that there
is only one case and no catch-all _
case.
Conclusion
These two examples are not that different, so why do I like the Rust version better? It scales better. No information is lost or hidden and it can easily grow to include other types of errors. There is less boilerplate, and you can easily chain functions together and abort in the middle.
To demonstrate, we can rewrite part of our get_file_contents
from
let mut file = File::open("file.txt")?;
file.read_to_end(&mut contents)?;
to
File::open("file.txt")?.read_to_end(&mut contents)?;