Nicer error reporting

We all can do nothing but accept the fact that errors will occur. And in contrast to many other languages, it’s very hard not to notice and deal with this reality when using Rust: As it doesn’t have exceptions, all possible error states are often encoded in the return types of functions.

Results

A function like read_to_string doesn’t return a string. Instead, it returns a Result that contains either a String or an error of some type (in this case std::io::Error).

How do you know which it is? Since Result is an enum, you can use match to check which variant it is:


# #![allow(unused_variables)]
#fn main() {
let result = std::fs::read_to_string("test.txt");
match result {
    Ok(content) => { println!("File content: {}", content); }
    Err(error) => { println!("Oh noes: {}", error); }
}
#}

Unwrapping

Now, we were able to access content of the file, but we can’t really do anything with it after the match block. For this, we’ll need to somehow deal with the error case. The challenge is that all arms of a match block need to return something of the same type. But there’s a neat trick to get around that:


# #![allow(unused_variables)]
#fn main() {
let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) => { content },
    Err(error) => { panic!("Can't deal with {}, just exit here", error); }
};
println!("file content: {}", content);
#}

We can use the String in content after the match block. If result were an error, the String wouldn’t exist. But since the program would exit before it ever reached a point where we use content, it’s fine.

This may seem drastic, but it’s very convenient. If your program needs to read that file and can’t do anything if the file doesn’t exist, exiting is a valid strategy. There’s even a shortcut method on Results, called unwrap:


# #![allow(unused_variables)]
#fn main() {
let content = std::fs::read_to_string("test.txt").unwrap();
#}

No need to panic

Of course, aborting the program is not the only way to deal with errors. Instead of the panic!, we can also easily write return:

# fn main() -> Result<(), Box<std::error::Error>> {
let result = std::fs::read_to_string("test.txt");
let _content = match result {
    Ok(content) => { content },
    Err(error) => { return Err(error.into()); }
};
# Ok(())
# }

This, however changes the return type our function needs. Indeed, there was something hidden in our examples all this time: The function signature this code lives in. And in this last example with return, it becomes important. Here’s the full example:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = std::fs::read_to_string("test.txt");
    let content = match result {
        Ok(content) => { content },
        Err(error) => { return Err(error.into()); }
    };
    println!("file content: {}", content);
    Ok(())
}

Our return type is a Result! This is why we can write return Err(error); in the second match arm. See how there is an Ok(()) at the bottom? It’s the default return value of the function and means “Result is okay, and has no content”.

Question Mark

Just like calling .unwrap() is a shortcut for the match with panic! in the error arm, we have another shortcut for the match that returns in the error arm: ?.

That’s right, a question mark. You can append this operator to a value of type Result, and Rust will internally expand this to something very similar to the match we just wrote.

Give it a try:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("test.txt")?;
    println!("file content: {}", content);
    Ok(())
}

Very concise!

Providing Context

The errors you get when using ? in your main function are okay, but they are not great. For example: When you run std::fs::read_to_string("test.txt")? but the file test.txt doesn’t exist, you get this output:

Error: Os { code: 2, kind: NotFound, message: “No such file or directory” }

In cases where your code doesn’t literally contain the file name, it’d be very hard to tell which file was NotFound. There are multiple ways to deal with this.

For example, we can create our own error type, and then use that to build a custom error message:

#[derive(Debug)]
struct CustomError(String);

fn main() -> Result<(), CustomError> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .map_err(|err| CustomError(format!("Error reading `{}`: {}", path, err)))?;
    println!("file content: {}", content);
    Ok(())
}

Now, running this we’ll get our custom error message:

Error: CustomError(”Error reading test.txt: No such file or directory (os error 2)”)

Not very pretty, but we can easily adapt the debug output for our type later on.

This pattern is in fact very common. It has one problem, though: We don’t store the original error, only its string representation. The often used failure library has a neat solution for that: Similar to our CustomError type, it has a Context type that contains a description as well as the original error. The library also brings with it an extension trait (ResultExt) that adds context() and with_context() methods to Result.

To turn these wrapped error types into something that humans will actually want to read, we can further add the exitfailure crate, and use its type as the return type of our main function. The full example will then look like this:

use failure::ResultExt;
use exitfailure::ExitFailure;

fn main() -> Result<(), ExitFailure> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .with_context(|_| format!("could not read file `{}`", path))?;
    println!("file content: {}", content);
    Ok(())
}

This will print an error:

Error: could not read file test.txt
Info: caused by No such file or directory (os error 2)