Untangle Your Errors With Enums

Ryan James Spencer

April 22 2020, 9:04PM

Do you find it far too easy to reach for panics or shoehorn pre-existing errors to fit your needs? Is it unsatisfying that there are no exceptions in Rust and challenging to adjust to handling errors with Result? Here is a fundamental method for modeling data that will help untangle error handling in your programs.

Programs laden with unwrap, expect, assert, and panic are quick to gain momentum, but this approach is clunky. For those coming from languages where exceptions are the norm for error handling, it can feel natural to reach for, but also awkward to use, something as blunt as a panic. Exceptions have handlers registered, whereas panics do not, which is the primary difference between the two.

Panics are for critical situations where a program has no other option but to commit suicide. These vital situations are why capturing a panic in Rust carries a stigma. Recovering from a panic depends on how the panic unwinds or aborts, which is not always under our control.

Handle errors in Rust with Result. However, for newcomers, it's not apparent how to best design error types. Result<A, B> implies that B could be anything and we don't want to put anything in there; ad hoc matching of strings or cross-referencing integers for errors is the pits.

The problem with strings, integers, and other unrefined types is that the range of values you can express with them is vast, and when it comes to errors, we want to categorize them neatly, so the range of things we can express is concise. Unstructured data is hard to check against, parse by a machine, and find in a codebase. If you do not want to be caught in molasses later in your project, error handling brevity and classification matter a lot.

Enter the endlessly useful enum. The beauty of enums is that we can refine and, therefore, narrow down the range of things we can express. Enums expose a handle other coders can rely on, whether they be consumers of your crate or internal maintainers. Enums optimize for categorization and aggregation, which makes errors easy to find in code.

To start, create a bare-bones error module in your project with a top-level Error enum. I'll put some things in here for demonstration purposes, but I'm sure you can extrapolate for your own needs:

use std::fmt::Display;

mod error {
  pub enum Error {
    IoError(std::io::Error),
    SerdeError(serde_json::Error),
    // ... and so on.
  }

  impl Display {
    // display implementations for each variant.
  }
}

Once we have this top-level Error, keep pushing; Maybe Error is too much of a grab bag. Keep clarifying your error types. In the above example, we only expressed external errors but we will inevitably need to express errors relating to our concerns. I'll extend our top-level error and even grow some new ones:

use std::fmt::Display;
use crate::token::Token;

mod error {
  pub enum Error {
    pub Vendor(VendorError),
    pub StdError(StdError),
    pub Internal(InternalError),
  }

  pub enum InternalError {
    pub Lex(LexError),
  }

  pub struct LexError {
    pub path: Path,
    pub line: i64,
    pub column: i64,
    pub token: Token,
  }

  pub enum VendorError {
    pub SerdeError(serde_json::Error),
    // ... and so on.
  }

  pub enum StdError {
    pub IoError(std::io::Error),
    // ... and so on.
  }

  impl Display {
    // display implementations for each variant.
  }
}

From such small beginnings we have grown a relatively comprehensive error type to use in a variety of situations for our program or library. With all of this in place there is little reason to turn to a panic. The astute observer noted that we had Display impls laying around. I've structured the output in the "NASA" style of error reporting, showing a 'stack' of errors. Each layer of the classification above might have nested descriptions with colons or some other nested format, for example:

<snip>
  //  top level, we start with [foo] to help describe things.
  impl Display for Error {
    fn fmt(error: Error, f: Formatter) -> {
      match error {
        Error::Vendor(ve) => fmt.write("vendor: {}", ve),
        Error::StdError(se) => fmt.write("stdlib:  {}", se),
        Error::Internal(ie) => fmt.write("internal: {}", ie),
      }
    }
  }

  impl Display for InternalError {
    fn fmt(error: InternalError, f: Formatter) -> {
      match error {
        Internal::Lex(e) => fmt.write("could not lex source: {}", e),
      }
    }
  }

  impl Display for LexError {
    fn fmt(le: LexError, f: Formatter) -> {
      fmt.write("unrecognized token `{}' in {}:{}:{}", le.token, le.path, le.line, le.column),
    }
  }
<snip>

If we had a lexing error we could get a nice output like this:

internal: could not lex source: unrecognized token `asdf' in src/main.rs:3:4

In a language with sum types, or as Rust calls them, enums, there's no excuse not to use them liberally for data modeling of all kinds. Being meticulous about how you design errors and using categorization as a guiding heuristic makes error handling a snap rather than a grind.


Join the Newsletter