Simple and idiomatic error structure

Hey there, I'm fairly new to Rust and I'm trying to grok the current landscape of custom errors and handling.

I have a program that I'm slowly growing that talks to Neovim over RPC and a Clojure REPL over a socket. This means there's a bunch of connections and parsing, all of which can fail in interesting and colourful ways. I've just been returning String as my error type up until now since it's the only thing I actually understood.

I've been reading everything I can on failure, error-chain and "just use plain errors with custom macros" but I don't fully understand how I'm actually supposed to use any of them. How do they interact with the EDN parsing and RPC library errors for example. Like, am I supposed to wrap every error type in my own error type?

I was wondering if anyone had any tips on wrangling a bunch of different error types into something that wasn't a string. I only have one use case so far where I might want to behave differently depending on what sort of error was returned, the rest of the errors are just echoed to the user, so strings have been fine up until this point.

The one that sort of began to make sense was failure, where the other errors I was seeing were cast into failure::Error. And I think it would let me attach context strings somehow which could also be useful.

What's the current defacto for small-ish projects? Is there one? Is everything in a state of flux and nothing is the obvious answer? Because from my days of Googling, it's really feeling like the latter. I feel like I should just use normal Error without failure, but I honestly don't understand what that entails. Especially when I throw various library errors into the mix!

I hope someone out there has some tips on this, it'll be greatly appreciated. Hopefully this thread will be a good place for others in my position in the future too.

Here's my code at the time of writing this post, Result<T, String> a plenty: https://github.com/Olical/conjure/tree/ba25c3891cbe7dc24fd9402d728ec037ad3f53e1

Personally I've been using this pattern for error handling for over 2 years now:

type CarResult<T> = Result<T, CarErr>;

enum MyErr { 
    UnknownBrand(String), // note that you can put arguments of any type in here
    // more variants
}

struct Car {
    color: String,
    model: String,
    brand: String,
}

impl Car {
    pub fn new(brand: String, model: String, color: String) -> CarResult<Self> {
        if !["Tesla", "GM", "Toyota"].contains(&brand) {
            return Err(CarErr::UnknownBrand(brand));
        }
        // similar input validation for self.color and self.model
        Ok(Self {
            color: color,
            model: model,
            brand: brand,
        })
    }

    pub fn make_yellow_tesla(self, model: String) -> CarResult<Self> {
        let mut new = Self::new(
            String::from("tesla"),
            model,
            String::from("yellow"),
        )?; // <- slightly contrived, but this demonstrates returning immediately on error
        Ok(new)
    }
}

The idea behind this is simple: Just define a bunch of errors together as an enum.
There are other approaches but in general those are used to hide the internals of errors, which I find distasteful as it hinders error recovery, and so I won't go into those.
This approach typically works well within a single module or even a single crate.
It's also possible to compose multiple error enum types, though going more than 2-3 levels deep with this strategy is inadvisable because after a while it just starts to become a large error mass (basically the programmer becomes the bottleneck due to the sheer amount of information there).

With multiple crates it starts to depend on the relation between those crates: If a crate A depends on a crate B, then it can of course access B's errors (assuming B made them publicly accessible), but B cannot access A's errors. Thus there are limits on how enum-based errors can be composed across multiple crates.
For example, if crate A also depends on crate C, and A needs B and C to communicate in a way that the errors of B and C can interact, then things start to get hairy and detail-dependent.

The failure crate's Fail trait and its predecessor-and-also-kind-of-successor trait std::error::Error can be seen as abstractions from the enum-based approach. In order to allow type-based matching, the Fail trait supports a downcast_ref() trait method.
The advantage of this approach is that it can be easier to make errors from multiple crates play nicely together.
The downsides are:

  • You're forced to Box your errors, which in general makes them more expensive than simple error enums
  • Despite the existence of downcast_ref(), there is still an ergonomicity penalty relative to the enum-based approach because you'll always need to account for the possibility of the downcast_ref() method itself failing.
  • The types of errors are hidden behind the opaque Box<Fail> type. Some may see this as an advantage: it is an abstraction after all. But as I hinted at earlier, error management (i.e. not simply bubbling them up, or dumping them to a log) is hindered by not knowing exactly what you're dealing with (which results from the use of the Box<Fail> type).

There is a 3rd way to handle errors, which is using the panic!() macro. you may have come across this when using the .unwrap() and .expect() on e.g. Option and Result values.
Using panic!() in libraries is typically a bad idea (aside from invariant verification) as it takes the option to properly manage, work around or even recover from the error away from the library user.
This is not a theoretical concern either: A little over a year ago I ran into this very issue when I was using the rust-zmq crate for a project. The issue has since been fixed, but I'll never forget the lesson it taught me.
Usage in binaries is fine, though it may be a disconcerting sight for a non-programmer to see the output of a panic!() call, so some judgment is called for here.

3 Likes