Engineer in Tokyo

Rust First Impressions: Error Handling

A cartoon image of a crab that was alerted to some danger, showing an exclamation point above the crab's head

For the 2024 edition of Advent of Code I decided to finally give Rust a try and implement the puzzle solutions in the language. Rust has been pretty stable for more than a decade so I suppose learning it was far past due.

I want to write some of my first impressions of the language as much to help sharpen and collect my thoughts as much as to share them with others. I’ll also be coming at this mostly from the perspective of a Go programmer since that’s probably my strongest language at the moment.

Given that I was mostly focusing on Advent of Code puzzles I haven’t really had a chance to dive into the many popular crates that are out there, so I’ll mostly be talking about core language features. Three big ones jump out as being different enough that they would trip up new learners, myself included. Those three are error handling, the type inference engine, and the ownership & borrowing model. I will be writing about each of these in separate blog posts. In this post I’ll be focusing on error handling.

The Result type

Rust returns errors as part of the Result type. Results are implemented as an enum with a return value XOR a error value and include a number of methods like expect, unwrap, unwrap_or, unwrap_or_default, and unwrap_or_else that simplify error handling. Each of these methods are basically an if statement that does different things in the case that an error is encountered. This heavily favors the style in Rust of chaining logic together and using closures which can easily get unwieldy.

// Why this?
if result.is_err_and(|x| x.kind() == ErrorKind::NotFound) { /* … */ }

// … when you could do this? (unfortunately, combining the ifs is unstable(?))
if let Err(e) = result {
  if e.kind() == ErrorKind::NotFound { /* … */ }
}

In Go errors are generally returned in a simple tuple and checked with if statements. This removes the need for wrapper type and makes the logic easier to follow. Go’s fmt package also includes errors that can be wrapped in other errors allowing error types to be retained at each stack level.

x, err := SomeFunc()
if err != nil && errors.Is(err, fs.IsNotExist) {
    // …
}

In Rust, the fact that the Result can have only one of either a return value or an error means that there is less of a chance that someone will attempt to use the return value when an error is given. In practice, I haven’t found this to be too much of an issue with Go and in some cases it’s useful to have both a return value and error but I suppose Rust’s approach is a bit safer overall.

One other thing that is nice about the Result type is that it is annotated with the #[must_use] attribute which allows the compiler to issue a warning if the error isn’t handled. In Go, popular linters will make this check but it’s nice that it’s built into the compiler for Rust.

Errors are values

Rust’s error handling is probably the easiest to grasp of the three features I mention here. Rust joins Go in taking the approach that errors are values. Rust doesn’t have exceptions as they have gone out of favor due to their tendency to cause jumps to unexpected areas of the codebase. Instead of exceptions, recoverable errors are returned as return values to be handled by the caller. Unrecoverable errors will generally be represented by the program panicking. Both Rust and Go are similar in that manner.

I am personally sympathetic to this given the general lack of ways to help programmers handle exceptions in other languages. For languages like JavaScript, Ruby, and Python functions are not annotated with the exceptions they throw so it’s very hard to know what errors could occur when calling it. Some languages like Java have checked exceptions. Java also makes the distinction between “exceptions”, which are recoverable errors, and “errors” which are (generally) not.

While widely used by languages, exceptions can cause programs to jump to unexpected areas of the codebase and it can be very hard to follow what code will be executed next after an exception is thrown. Even in the best case scenario you need to follow the function’s call chain all the way back to where the exception is caught, and even then it could be re-thrown!

Treating errors as values handles these issues by returning errors from function calls. It also allows for more creative handling of errors by recording them or composing them with other user-defined types. In Go, errors can be composed to great effect to simplify repetitive error handling.

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

This doesn’t seem to be done as much in Rust as it has opted to have the built-in question mark for repetitive error propagation. So there isn’t as much opportunity to make use of the idea that errors are values quite as much.

w.write(buf[a:b])?;
w.write(buf[c:d])?;
w.write(buf[e:f])?;

The Error trait

In Rust errors returned in a Result must implement the Error trait. Rust allows you to create user-defined error types but the Error trait is rather verbose to implement. Here is probably the shortest way to create a custom error using the standard library.

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct SomeError;

impl fmt::Display for SomeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Some error happened")
    }
}

impl Error for SomeError {}

Go’s error interface is rather simple by comparison and only requires you to implement the Error method which returns the error as a string. There are some Rust crates like anyhow and thiserror to make creating error types easier but right now I find them to be a bit overkill.

type SomeError struct {}

func (e SomeError) Error() string {
return "Some error happened"
}

Questions but not many answers

I mentioned that treating errors as values can create repetitive error handling code. Rust has the built-in question mark (?) which inserts logic to the effect of:

// This is equivalent
SomeFunc()?;

// … to this.
let result = SomeFunc();
if let Err(e) = result {
    return Err(e);
}

This is nice because it can make the code much more concise. However, it can also make it harder to follow where the errors are coming from. Rust’s use of question marks and long method chains can also make it hard to know what code is covered by tests and if all error conditions are being tested since coverage tools often look at code lines.

// Are all of these errors being tested?
SomeFunc()?.parse::<usize>()?.checked_add_signed(i).ok_or_else(|| 0)?;

Propagating errors in Rust is a bit tricky and it took me a while to figure out how to do it right. To return an Error from a function that may return any number of error types, it needs to be returned as a Box<dyn Error>. The aforementioned anyhow and thiserror libraries have more features and are widely used. Error types are also returned directly and pre-defined as an enum and/or converted between error types with the From trait.

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

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

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

Now we can define a function that returns the SomeError error type.

fn some_func() -> Result<(), SomeError> {
    // We can return std::num::ParseIntError and std::io::Error here and it will be converted to SomeError.
}

Though, perhaps providing more specificity with regard to errors, this seems to me to be just needlessly complicated. I need to implement conversions between error types and enumerate every error type that could possibly be returned from the function. Go just allows us to return errors by wrapping them and test the error types later.

func SomeFunc() error {
    // This could be strconv.NumError or io.EOF
    err := SomeOtherFunc()

    // Using SomeError declared above. Both the error returned by SomeOtherFunc
    // and the SomeError are wrapped.
    return fmt.Errorf(%w: %w, SomeError{}, err)
}

Now, most of the time we don’t need to care about the underlying error types but if we did we can use the errors package. No need to convert between types. Go’s wrapped errors also allow us to return an error that is multiple error types (in this case SomeError and io.EOF) providing for greater flexibility in how the errors are handled.

var numErr *strconv.NumError
if errors.Is(err, io.EOF) {
    // Matches io.EOF but not strconv.NumError
}

var someErr *SomeError
if errors.As(err, &SomeError) {
    // Matches all SomeErrors
}

Final Thoughts

While I wish error handling in Rust was as simple as Go, I appreciate the lack of exceptions and I think the Result type fits well into Rust code. However, I’m more of a fan of Go’s simpler approach to errors and error handling. The more code I’ve written the more I appreciate having fewer language features and syntactic sugar, preferring code that is optimized for reading.

Go has some esoteric issues like using errors.Is for static error values vs. using errors.As for matching against an instance of an error type but this complexity pales in comparison to Rust’s error handling. A Rust-like question mark proposal, and continuing discussion, was introduced to reduce repetition but I see introducing features like this as mostly a misadventure.

Update: This post used to include language where I compared Rust’s Result type to a tuple. This is technically incorrect and I updated the language to be more clear about the differences between returning a tuple with an error vs. an enum (tagged union) type.

NOTE: The image at the top of this post was generated by Gemini 1.5 Pro using the prompt “A cartoon image of a crab that was alerted to some danger, showing an exclamation point above the crab’s head”. It was further hand edited to remove the background color.