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 realitywhen 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 Resultthat contains eithera Stringor 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:

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

Aside:Not sure what enums are or how they work in Rust?Check this chapter of the Rust bookto get up to speed.

Unwrapping

Now, we were able to access the 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:

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

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:

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

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:

  1. fn main() -> Result<(), Box<std::error::Error>> {
  2. let result = std::fs::read_to_string("test.txt");
  3. let _content = match result {
  4.     Ok(content) => { content },
  5.     Err(error) => { return Err(error.into()); }
  6. };
  7. Ok(())
  8. }

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:

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

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”.

Aside:Why is this not written as return Ok(());?It easily could be – this is totally valid as well.The last expression of any block in Rust is its return value,and it is customary to omit needless returns.

Question Mark

Just like calling .unwrap() is a shortcutfor 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 tothe match we just wrote.

Give it a try:

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

Very concise!

Aside:There are a few more things happening herethat are not required to understand to work with this.For example,the error type in our main function is Box<dyn std::error::Error>.But we’ve seen above that readto_string returns a std::io::Error.This works because ? expands to code that _converts error types.

Box<dyn std::error::Error> is also an interesting type.It’s a Box that can contain any typethat implements the standard Error trait.This means that basically all errors can be put into this box,so we can use ? on all of the usual functions that return Results.

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:

  1. 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 would 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:

  1. #[derive(Debug)]
  2. struct CustomError(String);
  3. fn main() -> Result<(), CustomError> {
  4. let path = "test.txt";
  5. let content = std::fs::read_to_string(path)
  6. .map_err(|err| CustomError(format!("Error reading `{}`: {}", path, err)))?;
  7. println!("file content: {}", content);
  8. Ok(())
  9. }

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

  1. 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 typethat 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 typesinto 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.

Let’s first import the crates by addingfailure = "0.1.5" and exitfailure = "0.5.1" to the [dependencies] sectionof our Cargo.toml file.

The full example will then look like this:

  1. use failure::ResultExt;
  2. use exitfailure::ExitFailure;
  3. fn main() -> Result<(), ExitFailure> {
  4. let path = "test.txt";
  5. let content = std::fs::read_to_string(path)
  6. .with_context(|_| format!("could not read file `{}`", path))?;
  7. println!("file content: {}", content);
  8. Ok(())
  9. }

This will print an error:

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