Dmitrii Aleksandrov

Designing Error Types in Rust Applications

Posted on 13 mins

Error Handling Rust Tech

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

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:

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:

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:

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:

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


  1. Or unless you have a widely used function in a huge codebase where the callers could be considered an uncontrollable “outside world” from your module’s point of view. ↩︎

  2. XlsxError doesn’t over-expose irrelevant details. And, to quote myself :

    From periodically skimming the method docs, I know that the returned error subsets unpredictably overlap between the methods. So, it would be hard to extract a meaningful separate subset that doesn’t overlap with anything.

     ↩︎
  3. To be fair, it still possesses some advantages of structured errors, like DRY error messages, jump-to-variant-definition, reliable pattern matching (although no longer exhaustive), autogenerated docs with all possible variants (although there are extra, unused variants in the list). ↩︎

  4. Keep in mind that we’re talking about application error handling. In a public library, you may want to mark these per-function enums as #[non_exhaustive] to prevent breaking changes as the library evolves. In application code, breaking changes are a smaller problem because the resulting refactoring is entirely under your control and doesn’t cause any downstream trouble. ↩︎

  5. Scanning into every intermediate enum down to every “leaf” variant to avoid missing the variants that we’re interested in:

    let b: Option<BError> = match foobar() {
        Ok(()) => None,
        Err(foobar) => match foobar {
            FoobarError::Foo(foo) => match foo {
                FooError::A => None,
                FooError::B(b) => Some(b),
            },
            FoobarError::Bar(bar) => match bar {
                BarError::B(b) => Some(b),
                BarError::C => None,
            },
        },
    };
    if let Some(b) = b {
        // Do something special with `b`...
    }
    
     ↩︎
Comments