Designing Error Types in Rust Applications

12 min read Original article ↗
Table of Contents

TL;DR: use an enum per function, instead of a global Error enum.

The “Error Handling” series

This is the fourth post in my “Error Handling” series.

I suggest reading “Why Use Structured Errors in Rust Applications?” before this one. There, I

  • define “structured errors”;
  • describe why error handling is different in libraries vs applications;
  • discuss the tradeoffs of dynamic vs structured errors in applications; and
  • make the case for structured errors.

Library vs application needs

Now, as we’ve narrowed our focus down to structured errors, let’s discuss why application error types should be very different from library errors that you commonly see:

  • Libraries don’t know all their callers and have to anticipate a wide range of use cases.
  • This usually implies that libraries allow their callers to pattern-match specific error cases and programmatically extract the error details.
  • Libraries care about backward compatibility of their public interface, which includes the error types.
  • This implies that they should be careful with exposing new error variants and details.
  • But intentionally adding new variants and details shouldn’t be a breaking change. Most library errors should be #[non_exhaustive] .

In this post, by “libraries” I mean “public, reusable libraries”. For my purposes, private libraries in a workspace still count as “application code”.

Unless you expose structured error data to the outside world 1, the situation in your app is exactly the opposite:

  • You know every place where every function is called, and your needs at every call site.
  • In most cases, you don’t pattern-match specific errors and instead just propagate the error, perhaps with some additional context.
  • You don’t need to care about the stability and backward compatibility of your error types. You can freely refactor your entire codebase as necessary. Unless you have a huge codebase, it’s easy to do.

Understanding this, let’s finally discuss how you should define your error types.

Don’t use one big enum for everything

In the Rust library ecosystem, it’s common to see one big crate-level Error enum that’s returned from every function. There are fair reasons for this:

  • It’s the easiest option for the author. It keeps the code DRY, concise, and free from type conversions.
  • It’s easier to propagate. When you call multiple functions from a library, and they all return the same error type, you can just propagate it without creating your own wrapper enum.
  • A single error type is easy to find.
  • It doesn’t “pollute” the docs and autocomplete suggestions with a long list of separate *Error types.
  • It’s easier to pattern match, because such enums are usually “flat”. We’ll discuss this in the next section.

See also how BurntSushi explains choosing this approach for his jiff library .

But you shouldn’t blindly copy it in your application!

Modularity

It’s easy to see that lumping every possible error into one global enum is anti-modular.

This works for smaller, “pure” libraries. They have a narrow and well-defined scope. rust_xlsxwriter::XlsxError is a “global” error enum with 33 variants, but it’s still a cohesive description of what can go wrong when writing an Excel file. 2

Your application probably does more things (especially, IO), has much more diverse error cases, and a greater number of cases overall. These cases don’t always overlap between multiple application features. Some cases are actually handled locally inside some feature’s module and shouldn’t be public outside of that.

When you ignore this, you get horror stories like a single match statement with 54 arms for every possible error in the app or a 1000-line error enum where lost people accidentally add duplicate variants . In cases like that, one big enum actively hurts code quality.

“Modular Errors in Rust” gives some arguments for splitting library errors, too.

Precise signatures

With a “catch-all” enum, the signature no longer accurately reflects the errors that a function can return. It contains many irrelevant error variants that are never returned in practice. To get a clear understanding of the function’s behavior, you have to either rely on fragile hand-written docs or inspect the implementation. In that sense, a large enough “catch-all” enum becomes weirdly similar to an opaque type like anyhow::Error. 3

It’s possible to be precise and exhaustive while also staying DRY, if you extract common variants into separate types:

Before
#[derive(Debug, thiserror::Error)]
enum Error {
    #[error("a")]
    A,
    #[error("b")]
    B,
    #[error("c")]
    C,
}

/// ## Errors
///
/// - [Error::A] if ...
/// - [Error::B] if ...
fn foo() -> Result<(), Error> {
    // ..
}

/// ## Errors
///
/// - [Error::B] if ...
/// - [Error::C] if ...
fn bar() -> Result<(), Error> {
    // ..
}
After
#[derive(Debug, thiserror::Error)]
#[error("b")]
struct BError;

#[derive(Debug, thiserror::Error)]
enum FooError {
    #[error("a")]
    A,
    #[error(transparent)]
    B(#[from] BError),
}

#[derive(Debug, thiserror::Error)]
enum BarError {
    #[error(transparent)]
    B(#[from] BError),
    #[error("c")]
    C,
}

fn foo() -> Result<(), FooError> {
    // ..
}

fn bar() -> Result<(), BarError> {
    // ..
}

This makes the author maintain a bit more code, but liberates him from maintaining hand-written docs without compiler assistance. The types give callers more confidence than those docs, and allow to pattern match exhaustively when needed. 4

Flat vs nested enums

Let’s evolve the last code example. There’s now a higher-level function foobar that calls foo and bar and propagates all their errors:

fn foobar() -> Result<(), FoobarError> {
    foo()?;
    bar()?;
    // ..
}

There are two different ways we could express FoobarError.

Flat enums

The “flat” implementation
#[derive(Debug, thiserror::Error)]
enum FoobarError {
    #[error(transparent)]
    A(AError),
    #[error(transparent)]
    B(BError),
    #[error(transparent)]
    C(CError),
}

impl From<FooError> for FoobarError {
    fn from(foo_error: FooError) -> Self {
        match foo_error {
            FooError::A(a) => Self::A(a),
            FooError::B(b) => Self::B(b),
        }
    }
}

impl From<BarError> for FoobarError {
    fn from(bar_error: BarError) -> Self {
        match bar_error {
            BarError::B(b) => Self::B(b),
            BarError::C(c) => Self::C(c),
        }
    }
}

// This approach forces us to refactor the existing lower-level code
// and extract all "leaf" errors into separate types:

#[derive(Debug, thiserror::Error)]
#[error("a")]
struct AError;

#[derive(Debug, thiserror::Error)]
#[error("c")]
struct CError;

#[derive(Debug, thiserror::Error)]
enum FooError {
    #[error(transparent)]
    A(#[from] AError),
    #[error(transparent)]
    B(#[from] BError),
}

#[derive(Debug, thiserror::Error)]
enum BarError {
    #[error(transparent)]
    B(#[from] BError),
    #[error(transparent)]
    C(#[from] CError),
}

In the “flat” style, each variant in the resulting enum corresponds to a “leaf” error case (A-C). We erase all intermediate knowledge about boo and bar. This is very similar to checked exceptions in Java. It’s extremely verbose, unfriendly to refactoring and doesn’t preserve any intermediate context.

This approach has an advantage, though. It allows the calling code to easily pattern match specific “leaf” errors (like BError) without knowing and worrying about all their possible origins (whether it has originated from foo or bar):

if let Err(FoobarError::B(b)) = foobar() {
    // Do something special with `b`...
}

This caller-side pattern matching is easy, robust, and future-proof.

But remember what I told you… applications very rarely pattern match specific errors!

Nested enums

In the “nested” style, the resulting variants are “higher-level” and directly correspond to the foo and bar calls that are happening in the function body:

#[derive(Debug, thiserror::Error)]
enum FoobarError {
    #[error(transparent)]
    Foo(#[from] FooError),
    #[error(transparent)]
    Bar(#[from] BarError),
}

I’ve always preferred nested enums. Their tradeoffs make more sense in my application:

  • They are much easier to implement. This is self-evident if you compare the size of the two code examples.

  • The variants correspond to the actual business actions that constitute foobar (Foo and Bar), rather than their internal details (like B).

  • This makes FoobarError more compact, meaningful, and suitable for studying.

  • This pattern is a lot more friendly towards adding context to errors, at both levels. The lower-level context (e.g., on FooError::A) is not lost. The higher-level context (e.g., on FoobarError::Foo) is per-function-call, which is very convenient and makes a lot of sense from the business standpoint.

  • If you never pattern match errors, nested enums “localize” refactoring. Changes don’t propagate many layers up. If you add a hypothetical BarError::D, you don’t need to change anything in FoobarError. This stands in nice contrast to checked exceptions in Java.

You might have experienced a slight cognitive dissonance, as I called per-function-call variants “high-level” and said that they don’t expose “internal details”. After all, isn’t your call graph an internal low-level detail that’s prone to change?

If we were talking about a stable public library with pattern-matching callers, you’d be correct. But remember that we’re talking about application error handling. We don’t need to preserve backwards compatibility, and the callers basically never pattern match. As you refactor your code, you simply refactor the error variants along with it. That creates a little friction, but also acts as documentation and forces you to reconsider the context messages, which is good.

Workarounds for pattern matching nested enums

So, you optimize for maintainability and use nested enums everywhere. But then, suddenly, you do need to match one specific “leaf” error and cover all of its origins. What are your options?

  1. Simply match nested cases and add unit tests:

    if let Err(FoobarError::Bar(BarError::B(b)) | FoobarError::Foo(FooError::B(b))) = foobar() {
        // Do something special with `b`...
    }
    

    Unless you write a huge, “deeply-exhaustive” 5 match statement, this code won’t catch future BErrors if you add a new origin later.

    Nevertheless, I wrote a snippet like this at work, and it serves me fine.

  2. Simply match the error message and add unit tests:

    if let Err(e) = foobar()
        && e.to_string().ends_with("b")
    {
        // Do something special with `e`...
    }
    

    This is another fragile yet pragmatic solution that I used at work once and it serves me fine.

  3. If you need a future-proof solution at the cost of verbosity, you can implement TryInto<BError> for FoobarError and all its “inner” errors, using exhaustive matching:

    if let Err(Ok(b)) = foobar().map_err(BError::try_from) {
        // Do something special with `b`...
    }
    
    Show verbose trait impls
    impl TryFrom<FoobarError> for BError {
        type Error = FoobarError;
    
        fn try_from(foobar: FoobarError) -> Result<Self, Self::Error> {
            // Intentionally exhaustive match to make sure that we check every underlying case.
            match foobar {
                FoobarError::Foo(foo) => foo.try_into().map_err(FoobarError::Foo),
                FoobarError::Bar(bar) => bar.try_into().map_err(FoobarError::Bar),
            }
        }
    }
    
    impl TryFrom<FooError> for BError {
        type Error = FooError;
    
        fn try_from(foo: FooError) -> Result<Self, Self::Error> {
            // Intentionally exhaustive match to make sure that we check every underlying case.
            match foo {
                FooError::A => Err(FooError::A),
                FooError::B(b) => Ok(b),
            }
        }
    }
    
    impl TryFrom<BarError> for BError {
        type Error = BarError;
    
        fn try_from(bar: BarError) -> Result<Self, Self::Error> {
            // Intentionally-exhausive match to make sure that we check every underlying case.
            match bar {
                BarError::B(b) => Ok(b),
                BarError::C => Err(BarError::C),
            }
        }
    }
    

    I’ve never needed this yet.

Other tips

When to reuse an error type between multiple functions

The TL;DR of this post is “define an enum per function”. However, I don’t do that every single time. Use your best judgement.

Sometimes, for example, I have a module that exports a single function, and that function is split into several private helpers that return some subset of errors. In that case, I wouldn’t bother and would just return the “full” error from the private helpers, unless I need a context message around them.

Where to put error types

Don’t define a global error.rs. Put an error type right above the function that returns it. “Error Handling in Rust” and “Modular Errors in Rust” recommend this too.

Methods are a little annoying, because impl blocks can’t contain type definitions. I usually put the errors right below an impl block.

Don’t create one-variant enums

You don’t need “extensibility”. Your app isn’t a stable public library! You can always refactor later.

Keep things simple. Just return the underlying type. Create a struct if you need to wrap it, or if you construct a “leaf” error and there’s nothing to wrap. Only create an enum when you have two or more variants to propagate.

non_exhaustive

Similarly, you don’t need #[non_exhaustive] errors in an application. You are always the caller and you can always refactor the match sites if you have any. Being forced to do that may be a good thing. When it’s not, you can add a wildcard match arm (_ => ..) voluntarily.

Naming error variants

Keep it concise. FooErr::Bar over FooErr::BarErr. Clippy has a lint for this. Also recommended in “Error Handling in Rust” .

Privacy of fields

This one’s easy. By default, everything’s naturally private. That’s one of the Rust’s “pits of success” . You write less code, and the compiler is able to perform better analysis, generate more dead code warnings, etc. Application code rarely pattern matches errors, so you rarely need to make the details public. When you do, you can quickly do this on demand.

Mixing anyhow and structured errors

Sometimes I notice people assuming that anyhow::Error is some sort of “dynamic typing” that has to “infect” the stack all the way up, and there’s no way to make it “typed” again. This isn’t true. You can isolate it and return to the typed land at any level:

#[derive(Debug, thiserror::Error)]
enum CallerErr {
    // I intentionally omit `#[from]`
    // to avoid auto-capturing `anyhow::Error`s from other function calls.
    //
    // They should probably go to their own error variants
    // with their own context messages.
    #[error("callee failed: {0}")]
    DynamicCallee(anyhow::Error),
    // ..
}

fn typed_caller() -> Result<(), CallerErr> {
    // ..
    dynamic_callee().map_err(CallerErr::DynamicCallee)?;
    // ..
}

fn dynamic_callee() -> anyhow::Result<()> {
    // ..
}

Incremental rewrites from anyhow are quite easy. This is a common pattern as applications mature.

Only siths deal in absolutes

I made a lot of prescriptive statements in this post. This is how I lead my project at work. But this is a nuanced topic, full of tradeoffs that depend on your project. You don’t have to follow my advice.


Good articles that I haven’t linked anywhere else in the post:

My other posts about error handling :

  1. “Rust Solves The Issues With Exceptions”
  2. “Why Use Structured Errors in Rust Applications?”
  3. “Go Didn’t Get Error Handling Right”
  4. “Designing Error Types in Rust Applications”

Discuss