Unrecoverable errors

These errors are a sign that the developer made a mistake. An example of this, could be trying to access an index that is out of bounds. For example:

1
2
3
4
5
6
7
8
fn get_number() -> usize {
    return 5;
}

fn main() {
    let numbers = [1, 2];
    println!("{}", numbers[get_number()]);
}

If we run that code, we will get the following error, and the program will crash:

1
2
thread 'main' panicked at src/main.rs:7:20:
index out of bounds: the len is 2 but the index is 5

We can trigger an unrecoverable error manually, by using panic!:

1
2
3
fn main() {
    panic!("Something went wrong!");
}

In this case, we’ll get the following output:

1
2
thread 'main' panicked at src/main.rs:2:5:
Something went wrong!

Recoverable errors

These errors, mean, something went wrong, but the program can continue execution. An example could be sending an HTTP request. There are many reasons why this could fail, and the program can proceed a different way depending on the cause of the error.

For recoverable errors, Rust uses the Result type. The Result type, is an enum that looks like this:

1
2
3
4
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Where T is a type that will be returned in case of success, and E is a type that will be returned in case of error.

Since Result is an enum, we can use match to do different things depending on if the result is a success or an error:

1
2
3
4
5
6
7
8
9
10
11
12
use std::env;

fn main() {
    match env::current_dir() {
        Ok(t) => {
            print!("The current directory is: {}", t.display());
        },
        Err(e) => {
            print!("There was an error: {}", e);
        }
    }
}

In the example above, we print the current directory if we are able to get it. Otherwise, we print an error message. It’s the same as doing:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::env;

fn main() {
    let res = env::current_dir();
    match res {
        Ok(t) => {
            print!("The current directory is: {}", t.display());
        },
        Err(e) => {
            print!("There was an error: {}", e);
        }
    }
}

Creating our results

As we already saw, Result is an enum with two different values and each value can hold a variable of a generic type:

1
2
3
4
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Following this, we can create a Result with the value Ok like this:

1
Ok(some_variable);

And a Result with the value Err with:

1
Err(some_variable)

As an example, this code will sometimes return an error, and sometimes it will return a value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use rand::Rng;

fn do_something() -> Result<bool, i8> {
    let num: i8 = rand::thread_rng().gen_range(0..3);
    if num == 0 {
        return Ok(true);
    } else {
        return Err(num);
    }
}

fn main() {
    match do_something() {
        Ok(r) => {
            println!("The result is: {}", r);
        },
        Err(e) => {
            println!("The error is: {}", e);
        }
    }
}

We can see from this example that the types for the value and error can be anything we want.

Propagating errors

The Result type, works like any other generic type. If a function returns a Result<bool, i8> we can return this result from another function, and it will be propagated as expected:

1
2
3
4
5
6
7
8
9
10
11
12
fn do_nothing() -> Result<bool, i8> {
    do_something()
}

fn do_something() -> Result<bool, i8> {
    let num: i8 = rand::thread_rng().gen_range(0..3);
    if num == 0 {
        return Ok(true);
    } else {
        return Err(num);
    }
}

In this case, do_nothing simply propagates the Result returned from do_something.

We get more flexibility when we use the ? operator. It allows us to get a success value or return the result with a simple syntax:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn change_result_type() -> Result<String, i8> {
    let r = do_something()?;
    if r {
        return Ok("All good".to_string());
    } else {
        return Ok("Not so good".to_string());
    }
}

fn do_something() -> Result<bool, i8> {
    let num: i8 = rand::thread_rng().gen_range(0..3);
    if num == 0 {
        return Ok(true);
    } else {
        return Err(num);
    }
}

Notice how change_result_type returns Result<String, i8>, which is different from the return type for do_something. In the example above, change_result_type is equivalent to the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn change_result_type() -> Result<String, i8> {
    match do_something() {
        Ok(r) => {
            if r {
                return Ok("All good".to_string());
            } else {
                return Ok("Not so good".to_string());
            }
        },
        Err(e) => {
            return e;
        }
    }
}

It’s important to keep in mind, that for this to work, the error types must be the same. In this example, the error type for change_result_type and do_something is i8.

Using traits as return types

So far, we have been using concrete types on our Results, but it’s also possible to use traits.

It’s common to use the trait std::error::Error as error type, but there are some gotchas we need to keep in mind.

Let’s say we want to write a function that uses std::error::Error as error type:

1
2
3
4
fn do_something() -> Result<bool, std::error::Error> {
    env::current_dir()?;
    Ok(true)
}

The example above might look valid, but it will fail compilation with this error:

1
2
3
4
5
6
7
error[E0277]: the size for values of type `(dyn std::error::Error + 'static)` cannot be known at compilation time
 --> src/main.rs:3:22
  |
3 | fn do_something() -> Result<bool, std::error::Error> {
  |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
  |
  = help: the trait `Sized` is not implemented for `(dyn std::error::Error + 'static)`

The most important thing to understand from this error message is that Rust only allows returning concrete, types because it needs to know the size of the type. Since a trait can be any of multiple types, we need a little trick to make it work.

The trick consists of wrapping our return type in a Box. A Box is simply a container pointing to some information that’s stored in the heap.

So, the correct code looks like this:

1
2
3
4
fn do_something() -> Result<bool, Box<dyn std::error::Error>> {
    env::current_dir()?;
    Ok(true)
}

Notice that we also prefixed std::error::Error with the keyword dyn. This is just because Rust tries to be explicit about when things are allocated to the heap. Anytime something is in the heap, we will see that keyword.

Defining our own error types

When we define an error type, we want to make sure we implement the std::error::Error trait. The easiest way looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt;

#[derive(Debug, Clone)]
struct OurError;

impl fmt::Display for OurError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Just a custom error")
    }
}

impl std::error::Error for OurError {}

We start by defining our struct. We use derive to automatically implement Debug and Clone. The std::error::Error trait extends the Debug trait, so we need to implement it and this is the easiest way. The Clone trait is not strictly necessary, but it will most likely be useful in the future.

We follow by implementing the fmt::Display trait, which is also extended by std::error::Error. This makes our error easy to print for debugging.

Finally, we specify that OurError implements the std::error::Error trait.

We can now return our custom error as desired:

1
2
3
fn do_something() -> Result<bool, Box<dyn std::error::Error>> {
    Err(Box::new(OurError))
}

Handling different errors

Since traits can be used as return types, it comes that there might be different kinds of errors contained in them. It’s common to want to handle different kinds of errors differently.

Sadly, the way to do this is Rust is very cumbersome. Let’s say we have a function and we expect it to return two different kinds of errors. In that case, we need to start by defining an enum with the expected error types:

1
2
3
4
5
#[derive(Debug, Clone)]
enum CustomErrorEnum {
    OurError(OurError),
    ErrorTwo(ErrorTwo),
}

Then we can make our function return this enum as error type:

1
2
3
fn do_something() -> Result<bool, CustomErrorEnum> {
    Err(CustomErrorEnum::ErrorTwo(ErrorTwo))
}

Finally, we can handle the different error types explicitly:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
    match do_something() {
        Ok(_) => {
            println!("All good");
        },
        Err(CustomErrorEnum::OurError(e)) => {
            println!("Got OurError: {}", e);
        },
        Err(CustomErrorEnum::ErrorTwo(e)) => {
            println!("Got ErrorTwo: {}", e);
        },
    }
}

This need for creating enums can be annoying, but it’s the only way I found to achieve this.

Conclusion

I found two things about error handling in Rust not that great:

  • The need for using Box when returning an interface
  • The inability of differentiating error types without creating a custom enum

I hope these are things that can be improved in future versions.

As usual, you can find working versions of all the code shown here in my examples’ repo.

[ debugging  programming  rust  ]
Sending E-mails From Rust With Brevo
Asynchronous Programming with Tokio
Programming Concurrency in Rust
Smart Pointers in Rust
Rust References Lifetimes