Node to Rust, Day 14: Managing Errors

Node to Rust, Day 14: Managing Errors

December 14, 2021

Introduction

So much of Rust’s documentation is explanation-heavy vs JavaScript’s example-oriented culture. The examples that do exist often involve unrelated concepts that add no value to the topic in question. I still aim to submit a PR to fix this example for read_to_string. That example inexplicably relies on the contents of a file to be a valid socket address to complete successfully. Maybe I expect too much, but I’m old and unlikely to change. I learn from example, from trial and error. I could sit through an explaination about how combustion is an exothermic chemical reaction and that a humans’ epidermis starts getting damaged at temperatures above 118 degrees Fahrenheit. Or I could touch a stove once.

I’ve read a lot of Rust while I learned. I kept trying to figure out the “right way” to do things. Judging from a lot of the code in public projects, I’m not the only one who wasn’t able to get comfortable with Rust right away. I’ve seen many crates I thought must be examples of good, practical code only to learn that the authors were on the same journey I was. It’s difficult to write good Rust when real world code is a mess of people trying to figure things out and documentation amounts to a pile of Lego without instructions.

Error handling is a good example of this problem turned up to 11. The gap from “This is what a Result is and how ? works” to being useful is massive. It’s not something you can ignore, either. It’s a major hurdle to being productive.

The code in this series can be found at wasmflow/node-to-rust

This guide is not a comprehensive Rust tutorial. This guide tries to balance technical accuracy with readability and errs on the side of “gets the point across” vs being 100% correct. If you have a correction or think something needs deeper clarification, send a note on Twitter at @jsoverson, @candle_corp, or join our Discord channel.

Error handling in Rust

The one thing you need to know right now is that you must start caring more about errors than you probably ever have before.

When you start a project, the first thing you think about needs to be “How do I manage errors?”

Dealing with multiple error types

Let’s take a look at the markdown renderer that wouldn’t compile from yesterday.

use std::fs::read_to_string;

fn main() -> Result<(), std::io::Error> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String, std::io::Error> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}

The problem with this code stems from mismatched types. We return a Result with an error type of io::Error but we’re using ? in two places, one of which returns a different kind of error. read_to_string returns a Result<String, io::Error> which is great, but env::var() returns a Result<String, env::VarError>.

We need a general type that matches multiple errors. If you’ve been following along day-by-day, then you know this means one of two things: traits or enums.

Option 1: Box<dyn Error>

This option is a good learning exercise. It’s not code you should write in any meaningful project.

Boxing your errors relies on those errors implementing the Error trait.

What’s a Box?

Rust must know the size of everything at compile time. Since a dyn [trait] value has lost its concrete type (see: Day 10: From Mixins to Traits), Rust can’t know its size. It’s “unsized.” A reference, on the other hand, does have a concrete size. It’s the size of a pointer. On a 32-bit machine its 32 bits. A 64-bit machine, it’s 64 bits. I know, whoa.

But we can’t simply return a reference willy nilly. Referenced data has to live somewhere. If it lives in (is owned by) our function then Rust won’t let us return a reference at all. The value’s lifetime will be too short. We’ve dealt with lifetimes all over this guide, they’ve just been hidden. We haven’t had to tackle them directly but we’re getting closer.

Think of Box-ing something as taking a value, putting it somewhere where it’ll live for a long time, and holding a pointer to that location. It’s how we get around wanting or needing to return a reference for a value that we would normally drop.

Explanations like these are exactly why I have the disclaimer at the top of each post. There’s a lot more to Box, but there’s plenty already written about it when you want to learn more.

The code to make this work is below:

use std::{error::Error, fs::read_to_string};

fn main() -> Result<(), Box<dyn Error>> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String, Box<dyn Error>> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}

This often works, but if you remember from Day 13: Results & Options, the Result type doesn’t constrain the error’s type. You will eventually encounter errors that do not implement the Error trait and this method falls flat.

Option 2: Create your own custom Error type

Using dyn [trait] crosses the dyn barrier which results in lost type information (as you remember). It’s a handy way of getting code running but it’s not a longterm solution.

Creating your own error type gives you more control over what you want to expose externally or handle explicitly. Error types can be structs or enums. Custom errors that account for multiple errors will frequently be an enum or involve an enum internally (often called ErrorKind).

enum MyError {}

A good Rust citizen produces errors that implement the Error trait, which you can start by writing:

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

Have you committed VS Code’s Quick fix we talked about it in Day 9 to muscle memory yet?

VS Code quick fix menu on impl Error for MyError

If so then that red squiggly line might have triggered you to go ahead and run the Implement default members action.

VS Code auto-filled Error implementation

Yikes. Good news, though: you can delete it. We don’t need it. Those defaults are fine. The red squiggly line was actually because the Error trait has two supertraits, Display and Debug, that need to be implemented.

A supertrait refers to a trait that is a “superset” of another trait. Supertraits confused me at first for two reasons.

  1. Super [anything] invokes imagery of something out of the ordinary, something special. They’re not.
  2. In programming, super is frequently coupled with inheritance which Rust tells you time and time again it doesn’t have.

The important thing to remember is that a supertrait is an additional, required trait. You must implement the supertraits in addition to the desired trait. We declare a trait with supertraits when we want to be able to use the supertrait’s methods from within our trait.

Implementing Debug and Display is straightforward and similar to what we’ve seen before:

#[derive(Debug)]
enum MyError {}

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

impl std::fmt::Display for MyError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "Error!") // We have nothing useful to display yet.
  }
}

In this guide I frequently prefix items with their full namespace, i.e. std::fmt::Display vs Display. That’s not necessary. I do it as a compromise between clarity and terseness.

After changing all of our Results to return MyError, our code now looks like this:

use std::fs::read_to_string;

fn main() -> Result<(), MyError> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String, MyError> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}

#[derive(Debug)]
enum MyError {}

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

impl std::fmt::Display for MyError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "Error!")
  }
}

This code doesn’t yet compile though. Rust outputs two errors, both of which are the same: ? coudn't convert the error to `MyError`

[snipped]
error[E0277]: `?` couldn't convert the error to `MyError`
   --> crates/day-14/custom-error-type/src/main.rs:10:39
    |
9   | fn render_markdown() -> Result<String, MyError> {
    |                         ----------------------- expected `MyError` because of this
10  |   let file = std::env::var("MARKDOWN")?;
    |                                       ^ the trait `From<VarError>` is not implemented for `MyError`
    |
    = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
    = note: required because of the requirements on the impl of `FromResidual<Result<Infallible, VarError>>` for `Result<String, MyError>`
note: required by `from_residual`
[snipped]

Just because we have a custom error type doesn’t mean that Rust knows how to convert other errors into it. The helper text shows us just what we need to do, though. We need to implement From<env::VarError> and From<io::Error> for MyError.

The From, Into, TryFrom, and TryInto traits

The From, Into, TryFrom, and TryInto traits are the root of many magical conversions. Whenever you see .into(), you’re (usually) seeing the result of implementing one or several of these traits.

Implementing From gives you the inverse Into for free. TryFrom does the same for TryInto. The Try* traits are for conversions that can fail. They return a Result.

The implementations for MyError are below. Notice that we’re adding variants to MyError to denote the error kind and also that our IOError variant wraps the original std::io::Error.

#[derive(Debug)]
enum MyError {
  EnvironmentVariableNotFound,
  IOError(std::io::Error),
}

impl From<std::env::VarError> for MyError {
  fn from(_: std::env::VarError) -> Self {
    Self::EnvironmentVariableNotFound
  }
}

impl From<std::io::Error> for MyError {
  fn from(value: std::io::Error) -> Self {
    Self::IOError(value)
  }
}

The complete implementation is below. Take note that we fleshed out the Display implementation now that we have variants to distinguish from:

use std::fs::read_to_string;

fn main() -> Result<(), MyError> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String, MyError> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}

#[derive(Debug)]
enum MyError {
  EnvironmentVariableNotFound,
  IOError(std::io::Error),
}

impl From<std::env::VarError> for MyError {
  fn from(_: std::env::VarError) -> Self {
    Self::EnvironmentVariableNotFound
  }
}

impl From<std::io::Error> for MyError {
  fn from(value: std::io::Error) -> Self {
    Self::IOError(value)
  }
}

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

impl std::fmt::Display for MyError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match self {
      MyError::EnvironmentVariableNotFound => write!(f, "Environment variable not found"),
      MyError::IOError(err) => write!(f, "IO Error: {}", err.to_string()),
    }
  }
}

It’s a lot of code just to use a question mark that’s supposed to make our lives easier…

Option 3: Use a crate

Every Rust programmer deals with errors and there’s loads of precedent out there. There’s no need to reinvent the wheel at this stage of your Rust journey. It’s much easier to leave it to a crate.

thiserror

thiserror (crates.io) gives you all of Option 2 with less headache and more functionality. The code below is a complete implementation that mimics the behavior in our custom error example.

use std::fs::read_to_string;

fn main() -> Result<(), MyError> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String, MyError> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}

#[derive(thiserror::Error, Debug)]
enum MyError {
  #[error("Environment variable not found")]
  EnvironmentVariableNotFound(#[from] std::env::VarError),
  #[error(transparent)]
  IOError(#[from] std::io::Error),
}
error-chain

error-chain is no longer maintained and is marked as deprecated. It’s still heavily relied upon and works fine the cases where I’ve used it. It makes basic error handling so simple that I think it is still worth mentioning. Getting passed the early frustration with error handling is more important than finding the perfect crate right away.

Another great option is error-chain (crates.io). error-chain gives you a lot more options and makes creating errors as easy as:

error_chain::error_chain!{}

Really, that’s it. You get an Error struct, an ErrorKind enum, a custom Result type aliased to return your Error, and more.

Below is a sample implementation for our example program.

use std::fs::read_to_string;

error_chain::error_chain! {
  foreign_links {
    EnvironmentVariableNotFound(::std::env::VarError);
    IOError(::std::io::Error);
  }
}

fn main() -> Result<()> {
  let html = render_markdown()?;
  println!("{}", html);
  Ok(())
}

fn render_markdown() -> Result<String> {
  let file = std::env::var("MARKDOWN")?;
  let source = read_to_string(file)?;
  Ok(markdown::to_html(&source))
}
Honorable mention: anyhow

The author of thiserror also publishes another popular error crate called anyhow. His words on the difference between anyhow and thiserror:

Use thiserror if you care about designing your own dedicated error type(s) so that the caller receives exactly the information that you choose in the event of failure. This most often applies to library-like code. Use Anyhow if you don’t care what error type your functions return, you just want it to be easy. This is common in application-like code.

I’ve used anyhow frequently and agree with the distinction above. anyhow is great for building command line utilities and other projects that won’t be used like a library.

Additional reading

Other crates

There are many other popular crates that make error handling less cumbersome. I haven’t used any of these in large projects and can’t give an opinion.

Wrap-up

Rust makes errors a priority. Once you start respecting them the way Rust forces you too, you’ll understand why. Robust error handling is one of the most valuable things you can take back to your JavaScript projects. You’ll learn how to isolate code that can fail and generate more meaningful error messages and fallbacks.

You can’t go wrong with using thiserror or error-chain for libraries. I use anyhow in my tests and for CLI projects frequently. They are all quality options and will turn error handling into one of the most frustrating parts of Rust into one of the things you love most.

As always, you can reach me personally on twitter at @jsoverson, the Candle team at @candle_corp, and our Discord channel.

Written By
Jarrod Overson
Jarrod Overson