Creating Ergonomic Error Types for Rust Traits

Failure is a fact of life. When we create software we must constantly contend with processes that can either succeed or fail. In fact, for many use cases there are far more ways that something can go wrong than it can go right. Networks drop packets, credentials expire, drives fill up, etc. Ironically, many languages choose to treat failure as an afterthought and merely handle error cases with scaffolding around the expected successful path via try catch mechanisms that introduce an alternate flow of control for error cases.

Rust's approach to fallible operations is to maintain a single flow of control and instead express success or failure through the type system. The idiomatic solution is to have functions return a Result<T, E> enum which will contain either the desired T or an error E which should inform the caller about what went wrong. This solution puts the reality of failure front and center for the programmer, encouraging thoughtful handling of both possible outcomes.

Importantly, Result is not just generic over the success type T, but also over the error type E. The definition puts absolutely no restrictions on what E can be.

1pub enum Result<T, E> {
2    Ok(T),
3    Err(E),
4}

This gives us tremendous flexibility in how we can implement and use the idiomatic error handling pattern. Let's explore the reasons why we might need flexibility in our error types and how it all comes together when crafting usable traits.

A Simple Error for a Simple Use Case

We'll start off by creating a bare-bones key-value utility that uses String for both the key and the value. We will enforce that all keys must not be an empty string, though, which will be our only error case. The implementation will be a very thin wrapper around the standard library's HashMap.

 1pub struct SimpleKVStore {
 2    store: HashMap<String, String>,
 3}
 4
 5impl SimpleKVStore {
 6    pub fn insert(
 7        &mut self,
 8        key: impl AsRef<str>,
 9        value: impl AsRef<str>,
10    ) -> Result<Option<String>, Something> {
11        if key.as_ref().is_empty() {
12            Err(Something{ ... })
13        } else {
14            Ok(self.store.insert(key.as_ref().into(), value.as_ref().into()))
15        }
16    }
17
18    pub fn get(&self, key: impl AsRef<str>) -> Result<Option<String>, Something> {
19        // Snip
20    }
21
22    pub fn remove(&mut self, key: impl AsRef<str>) -> Result<Option<String>, Something> {
23        // Snip
24    }
25}

Our implementation leaves open the question of what type will be used for the error case in our Result, though, by just putting in a placeholder Something type that we've not defined. As we saw above, there are zero restrictions on what type E can be for Err(E), including the unit type (), although specifying the unit type will produce a clippy warning.

Since the idea behind providing an error type is to communicate to the caller what has gone wrong, let's use the type system and actually create something meaningful.

 1pub struct EmptyKeyError {}
 2
 3impl SimpleKVStore {
 4    pub fn insert(
 5        &mut self,
 6        key: impl AsRef<str>,
 7        value: impl AsRef<str>,
 8    ) -> Result<Option<String>, EmptyKeyError> {
 9        if key.as_ref().is_empty() {
10            Err(EmptyKeyError {})
11        } else {
12            Ok(self.store.insert(key.as_ref().into(), value.as_ref().into()))
13        }
14    }
15    // Snip
16}

We now have a dedicated type, EmptyKeyError, that conveys information to the caller about why and how an error can arise when using our SimpleKVStore struct. Of course errors aren't simply there for the programmer who calls our code, they frequently end up getting logged and displayed, so we really should assist our users by implementing both Debug and Display for our error. The rust standard library agrees on this point, and those two traits are all that is required for a type to implement the error::Error trait as well.

 1#[derive(Debug)]
 2pub struct EmptyKeyError {}
 3
 4impl std::fmt::Display for EmptyKeyError {
 5    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 6        write!(f, "invalid key requested: empty string not allowed for key")
 7    }
 8}
 9
10impl std::error::Error for InvalidKeyError {}

Unfortunately, the real world is rarely so simple as our contrived example. Most processes can fail in more than one way, so we have to be prepared to communicate that complexity to the user.

Enumerating Failure

Let's introduce a second rule for our SimpleKVStore: all values kept in our store must also be a non-empty string

In order to enforce this new rule, we simply check whether or not the value being set or returned is an empty string. But we now have a problem in our return type. If we return an Err(EmptyKeyError) when the value string is empty, we are being neither honest nor helpful for our user. We need some way to communicate both sorts of error, which is exactly what enum can do for us.

 1#[derive(Debug)]
 2pub enum SimpleKVError {
 3    InvalidKey,
 4    InvalidValue,
 5}
 6
 7impl std::fmt::Display for SimpleKVError {
 8    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 9        let display_str = match *self {
10            Self::InvalidKey => "invalid key requested: empty string not allowed for key",
11            Self::InvalidValue => "invalid value: empty string not allowed for value",
12        };
13        write!(f, "{}", display_str)
14    }
15}
16
17impl std::error::Error for SimpleKVError {}

Our new SimpleKVError enum allows us to give users of the SimpleKVStore accurate, meaningful feedback when an error is encountered.

 1impl SimpleKVStore {
 2    pub fn insert(
 3        &mut self,
 4        key: impl AsRef<str>,
 5        value: impl AsRef<str>,
 6    ) -> Result<Option<String>, SimpleKVError> {
 7        if key.as_ref().is_empty() {
 8            Err(SimpleKVError::InvalidKey)
 9        } else if value.as_ref().is_empty() {
10            Err(SimpleKVError::InvalidValue)
11        } else {
12            Ok(self.store.insert(key.as_ref().into(), value.as_ref().into()))
13        }
14    }
15    // Snip
16}

Using simple enums for errors is often sufficient when you are creating a library. As the use cases grow and you add new constraints, you can add corresponding variants to the enum with the knowledge that users will be aware of these new failure modes because of rust's support for enforcing exhaustive matches (just make sure you indicate this with corresponding SemVer updates).

Unfortunately, this simple approach to specifying errors can start to fall apart when we move to describing behavior through traits.

Decoupling Systems with Traits

Our earlier SimpleKVStore is great for illustrative purposes, but such a thin wrapper on rust's HashMap isn't really a very useful tool. Key-value stores, however, are a crucial component in many distributed system architectures as a place to hold state, a common cache among nodes and much more. Because of this prevalence in system architectures there are also many different options to choose from for your key-value store backend: redis, memcached, Cloudflare Workers KV, and many more. If we want to write software that can work with any of these solutions we'll need to generalize their operation through a trait that sets out common behavior.

Let's keep this simple and ponder what would need to change from our SimpleKVStore implementation above. The most obvious change is to make all of the functions async.

 1pub trait KVStore {
 2    fn insert(
 3        &mut self,
 4        key: impl AsRef<str>,
 5        value: impl AsRef<str>,
 6    ) -> impl Future<Output = Result<Option<String>, Something>> + Send;
 7
 8    fn get(
 9        &self,
10        key: impl AsRef<str>,
11    ) -> impl Future<Output = Result<Option<String>, Something>> + Send;
12
13    fn remove(
14        &mut self,
15        key: impl AsRef<str>,
16    ) -> impl Future<Output = Result<Option<String>, Something>> + Send;
17}

Our new KVStore trait now generalizes the three operations that the SimpleKVStore implemented as async functions. This allows us to write code that does not rely on any specific implementation of the trait. We could have a test version of this code that is simply backed by a HashMap, another which utilizes redis and a third that uses a custom Postgres implementation. Any of these could be swapped for another and the calling code would not need to change.

A question remains, however. What should Something be in the Result::Err returned by each of our methods? Surely there are more modes of failure than we account for with our SimpleKVError enum, what with the possibility of network failures, timeouts and more. Of course, the value of the enum is that it can have many more variants, but when designing a trait will we be able to foresee all of the possible modes of failure in advance? Remember that we want our error type to convey enough information to the user that they can make an informed decision about whether recovery is possible and if so, how should it be done.

Ideally we would like to give as much information back to the user as we get from the underlying library on which we build our implementation. The redis crate, for instance, defines the RedisError type which gives detailed data about any failures that occur. How can we get this level of reporting back to callers of our trait?

Associated types to the rescue?

If we want to offer the full fidelity of underlying errors, one solution could be to update our trait to include an associated type that specifies the error type that will be returned:

 1pub trait KVStore {
 2    type StoreError;
 3
 4    fn insert(
 5        &mut self,
 6        key: impl AsRef<str>,
 7        value: impl AsRef<str>,
 8    ) -> impl Future<Output = Result<Option<String>, Self::StoreError>> + Send;
 9
10    fn get(
11        &self,
12        key: impl AsRef<str>,
13    ) -> impl Future<Output = Result<Option<String>, Self::StoreError>> + Send;
14
15    fn remove(
16        &mut self,
17        key: impl AsRef<str>,
18    ) -> impl Future<Output = Result<Option<String>, Self::StoreError>> + Send;
19}

Implementing this trait for a redis-backed version might then look something like the following:

 1impl KVStore for RedisKVStore {
 2    type StoreError = RedisError;
 3
 4    async fn insert(
 5            &mut self,
 6            key: impl AsRef<str>,
 7            value: impl AsRef<str>,
 8        ) -> Result<Option<String>, Self::StoreError> {
 9        // snip
10    }
11    // snip
12}

This approach has two glaring problems, however. The first is that by simply using RedisError we would have no way of communicating domain errors that are not strictly arising within the redis stack. Secondly, the use of an associated type in our return values now means that we have lost the decoupling that we originally sought. We cannot swap out a redis-based implementation for a memcached-based version unless our code strictly ignores all of the values returned in the Err variant, which defeats the whole point of providing meaningful errors. Associated types are powerful tools, but are wholely inappropriate for this sort of problem.

Traits for Trait Errors?

Programmers coming from languages like Java might wonder if the right solution is akin to the following interface definition, which specifies that the method can throw an IOException, including all of its subclasses, which could be examined via reflection:

1interface I {
2    void f() throws IOException;
3}

Given that rust has no concept of subclasses, the closest analog would be to specify that the error must implement a given trait. The standard library even has a trait, Error, specifically meant for error types that was mentioned above. And we can narrow down the possibilities for error types by creating our own custom error trait which requires Error as a supertrait.

 1pub trait KVError: Error {
 2    // Methods useful for understanding KVError implementations
 3}
 4
 5pub trait KVStore {
 6    fn insert(
 7        &mut self,
 8        key: impl AsRef<str>,
 9        value: impl AsRef<str>,
10    ) -> impl Future<Output = Result<Option<String>, Box<dyn KVError>>> + Send;
11
12    fn get(
13        &self,
14        key: impl AsRef<str>
15    ) -> impl Future<Output = Result<Option<String>, Box<dyn KVError>>> + Send;
16
17    fn remove(
18        &mut self,
19        key: impl AsRef<str>,
20    ) -> impl Future<Output = Result<Option<String>, Box<dyn KVError>>> + Send;
21}

Traits can only specify behavior for implementations, though, so we would really need to figure out what methods are important for understanding the sort of failure that has occurred. It would also be beneficial if we could get back any underlying error that is the root cause. Unfortunately, the burden for actually writing these methods falls on the implementors of the trait. In fact, it's work that will have to be duplicated for every single implementation.

What we really need is something that offers the elegance and simplicity of the earlier enum-based approach combined with an ability to also expose details that might be implementation-specific.

Composition to the Rescue

We need to decouple the broader category of error from the underlying details. We'll do this by explicitly enumerating the variety of errors that we think may be encountered in a key-value store, including a catch-all Other variant. Importantly, we mark this enum as non_exhaustive, indicating that the number of variants could grow and that matches against this type must always include a wildcard match arm. This is particularly important because new error kinds could be added which may never affect existing implementations.

 1#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
 2#[non_exhaustive]
 3pub enum KVErrorKind {
 4    Authentication,
 5    InvalidKey,
 6    InvalidValue,
 7    // snip
 8    TimedOut,
 9    Other,
10}
11
12impl std::fmt::Display for KVErrorKind {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        use KVErrorKind::*;
15        let disp = match *self {
16            Authentication => "authentication",
17            InvalidKey => "invalid key",
18            InvalidValue => "invalid value",
19            // snip
20            TimedOut => "timed out",
21            Other => "other",
22        };
23        write!(f, "{}", disp)
24    }
25}

In order to expose implementation-specific details of an error, we want to store any underlying errors as well. We'll do this using the same Box<dyn Error> approach we took with the method signatures earlier.

1#[derive(Debug)]
2pub struct KVError {
3    kind: KVErrorKind,
4    inner: Box<dyn Error + Send + Sync>,
5}

Since we've kept the fields of KVError private we provide methods that will be needed to create and interact with the type.

 1impl KVError {
 2    pub fn new<E>(kind: KVErrorKind, err: E) -> Self
 3    where
 4        E: Into<Box<dyn Error + Send + Sync>>,
 5    {
 6        Self {
 7            kind,
 8            inner: err.into(),
 9        }
10    }
11
12    pub fn kind(&self) -> KVErrorKind {
13        self.kind
14    }
15
16    pub fn get_inner_mut(&mut self) -> &mut (dyn Error + Send + Sync) {
17        &mut *self.inner
18    }
19
20    pub fn get_inner_ref(&self) -> &(dyn Error + Send + Sync) {
21        &*self.inner
22    }
23
24    pub fn into_inner(self) -> Box<dyn Error + Send + Sync> {
25        self.inner
26    }
27
28    pub fn downcast_inner<E>(self) -> Result<E, Self>
29    where
30        E: Error + Send + Sync + 'static,
31    {
32        if self.inner.is::<E>() {
33            let ok = self.inner.downcast::<E>();
34            Ok(*ok.unwrap())
35        } else {
36            Err(self)
37        }
38    }
39}

Finally, let's round this out by implementing both Display and Error for the KVError type. Note that we are explicitly implementing the source method for the Error trait, rather than relying on the default implementation which returns None. The source method is how we are able to build up a sort of ancestry for our error. You should implement Error::source whenever possible so that a full picture of failure modes can be presented to the user.

 1impl fmt::Display for KVError {
 2    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 3        write!(f, "{}", self.kind)
 4    }
 5}
 6
 7impl Error for KVError {
 8    fn source(&self) -> Option<&(dyn Error + 'static)> {
 9        Some(&*self.inner)
10    }
11}

Updating the KVStore trait to use our new error type yields the following.

 1pub trait KVStore {
 2    fn insert(
 3        &mut self,
 4        key: impl AsRef<str>,
 5        value: impl AsRef<str>,
 6    ) -> impl Future<Output = Result<Option<String>, KVError>> + Send;
 7
 8    fn get(
 9        &mut self,
10        key: impl AsRef<str>,
11    ) -> impl Future<Output = Result<Option<String>, KVError>> + Send;
12
13    fn remove(
14        &mut self,
15        key: impl AsRef<str>,
16    ) -> impl Future<Output = Result<Option<String>, KVError>> + Send;
17}

What has all of this achieved for us? We now have an error type for our trait which has the simplicity of an enum error type, but also the ability to expose implementation-specific details when desired. It's easy for trait implementors to provide concise, expressive errors. Users of an implementation are free to write idiomatic code matching on the KVErrorType and only delve into the details when they deem it necessary.

 1    // Implementation
 2    return Err(KVError::new(KVErrorKind::Authentication, authentication_error_from_lib));
 3
 4    // ...
 5
 6    // User
 7    match kv_error_from_result.kind() {
 8        KVErrorKind::Authentication => tracing::error!(
 9            "Encountered authentication error for key-value store: {}",
10            kv_error_from_result.get_inner_ref()
11        ),
12        // snip
13    }

The approach outlined above is not new. An excellent real-world example can be found in the rust standard library's implementation of the std::io::Error struct (source).


If you want to discuss this post or any other, please feel free to drop me a message on Instagram or over at Bluesky.