Error Handling Ergonomics vs Precision: Are Hierarchical/Modular Errors Worth the Boilerplate?

Hey.

So I have been going over some of the posts over various forums discussing this issue but couldn't come to any reasonable conclusion because there is no perfect answer.
here is the problem:


The Problem: "Phantom" Error Variants

Imagine an HTTP client crate with this error enum:

pub enum ClientError {
    UrlError(String),
    RequestError(String),
    ResponseError(ResponseErrorInfo),
    SerializationError(String),
    IoError(IoError),
    HeaderError(String),
    CookieError(String),
    UnknownError(String),
}

Now consider two functions:

  1. signup(): Can return RequestError ResponseError SerializationError CookieError
  2. challenge(): Returns Only ResponseError, RequestError.

Now imagine that various other functions can have various combinations of these...

The Issue:
If both return ClientError, users handling challenge() must account for all 8 variants, even though only 2 are possible. Sure, they can use _ => {}, but now:

  • Error handling becomes guesswork ("Which errors actually happen here?")
  • Documentation lies ("Says it can return IoError, but can it really?")

Approach 1: One Big Error Enum (The "Easy" Path)

// Pros: Simple propagation with ?, clean imports
impl fetch_user() -> Result<User, ClientError> {
    let res = make_request()?; // Auto-converts to RequestError
    parse_response(res)?;      // Auto-converts to ResponseError
    Ok(user)
}

Pros:

  • Dead-simple error returns
  • No conversion boilerplate
  • Unified API surface

Cons:

  • Forces users to handle nonexistent errors:
match error {
    ClientError::RequestError(e) => log(e),
    ClientError::ResponseError(e) => retry(),
    _ => {} // Wait... can this even happen? 🤔
}
  • Becomes a maintenance nightmare as the crate grows

Approach 2: Granular Errors (Composition Chaos)

Split into smaller enums:

// Base errors
#[derive(Debug)]
pub struct RequestError(String);
#[derive(Debug)]
pub struct ResponseError(String);
#[derive(Debug)]
pub struct SerializationError(String);

// First layer combo
pub enum RequestResponseError {
    Request(RequestError),
    Response(ResponseError),
}

// Second layer combo
pub enum RequestResponseSerializeError {
    RequestResponse(RequestResponseError),
    Serialize(SerializationError),
}

Error Propagation Without Magic:

// Manual From implementations required
impl From<RequestError> for RequestResponseError {
    fn from(e: RequestError) -> Self {
        Self::Request(e)
    }
}

impl From<ResponseError> for RequestResponseError {
    fn from(e: ResponseError) -> Self {
        Self::Response(e)
    }
}

// Now a function that uses these
fn fetch_data() -> Result<String, RequestResponseError> {
    let data = make_request()?;  // ? works because we implemented From
    let parsed = parse_response(data)?;
    Ok(parsed)
}

Matching Becomes Russian Dolls:

match error {
    RequestResponseSerializeError::RequestResponse(
        RequestResponseError::Request(RequestError(msg))
    ) => println!("Request failed: {}", msg),
    
    RequestResponseSerializeError::RequestResponse(
        RequestResponseError::Response(ResponseError(msg))
    ) => println!("Response failed: {}", msg),
    
    RequestResponseSerializeError::Serialize(
        SerializationError(msg)
    ) => println!("Serialize failed: {}", msg),
}

Pros:

  • Type safety: Functions clearly declare their possible failures
  • No phantom variants haunting your matches

Cons:

// This is what hell looks like in Rust:
1. 3 error types Ă— 2 conversions each = 6 manual From impls
2. Display impls? That's another 3 Ă— display implementations!
3. Every new error combination requires a new enum + conversions
4. Matching becomes digsite archaeology: "Error::Layer1(Layer2::Variant(...))"

Approach 3: Overlapping Enums (The Fragile Middle Ground)

Create overlapping enums for different functions:

// For update_user()
pub enum UpdateError {
    Request(String),
    Response(ResponseErrorInfo),
    Serialization(String),
}

// For delete_user()
pub enum DeleteError {
    Request(String),
    Response(ResponseErrorInfo),
    Io(IoError),
}

Pros:

  • Precise per-function errors
  • No phantom variants

Cons:

  • DRY Violation Central: If Request changes, you must update every enum containing it
  • Conversion Madness: Need manual From impls between similar enums
  • API becomes a maze of error types

What Would You Do?

I'm torn between:

  1. The "Easy" Lie (One enum, but pretend some variants don't exist)
  2. Boilerplate Purgatory (Precision at the cost of code bloat)
  3. Fragile Overlaps (Maintenance issues)

Is there a better way?
What is the idiomatic way to handle this?

Out of all of these the 3rd seems to be the most straight forward and least painful, but I would love to have some good alternatives that don't sacrifice readability and doesn't go into some insane language patterns that few people know.

(Code examples simplified for clarity)

2 Likes

I've never published any crate publicly, but in my own code I try to go for option 2. Even though it is a lot of upfront boilerplate as you put it, I find myself thanking my past self later on.

Approach 1 just leads to a lot of "just pass on this error to the caller" as "error handling" because most of the time the libraries don't bother to document which of the N enum variants it is actually possible to get from a function. I don't think even std::io::ErrorKind is documented like that. So in practise it feels like you might have as well just used Result<T, String> instead.

Option 3 might be better for users, but I definitely wouldn't want to maintain that.

It can’t, because operating systems don’t make strong promises about not emitting certain error codes for certain system calls. Often, you’re essentially asking for dynamic dispatch to kernel device drivers, which can and do return whatever error codes they want (intentionally or accidentally).

There could be more documentation about what errors are expected to occur, though.

This is my go-to citation for making great errors. It is a lot of boilerplate. I believe it corresponds to your approach 2. This section sums up the reasoning.

Error types should be located near to their unit of fallibility.

[ ...] the units of fallibility in our case are the operations we do, like downloading, reading a file, and parsing.

Now, things get a little subjective at this point on deciding what counts as two separate units or the same unit. In general, you should ask yourself the following two questions:

  1. Do they have different ways in which they can fail?
  2. Should they show different error messages should they fail?

If the answer to either of those questions is “yes”, then they should normally be separate error types.

Sometimes I end up with even more boilerplate than the article...

...because I'm not a fan of IIFEs, so instead I end up with patterns that look more like this.

impl<P: Into<Box<Path>>> From<(P, FromFileErrorKind)> for FromFileError {
    fn from((path, kind): (P, FromFileErrorKind)) -> Self {
        let path = path.into();
        Self { path, kind }
    }
}

impl Blocks {
	pub fn from_file<P: AsRef<Path> + ?Sized>(path: &P) -> Result<Self, FromFileError> {
		let path = path.as_ref();
		Self::from_file_inner(path).map_err(|kind| (path, kind).into())
	}
	
	fn from_file_inner(path: &Path) -> Result<Self, FromFileErrorKind> {
	    let data = fs::read_to_string(path).map_err(FromFileErrorKind::ReadFile)?;
	    Self::from_str(&data).map_err(FromFileErrorKind::Parse)
	}
}

(And though it's not my main point, this particular pattern also mitigates AsRef<Path> monomorphization bloat.)

There's also a section about how boilerplatey it is.

Approach 1 is the most common in my experience, but that doesn't mean it's the best.

This can be avoided by always using a single struct as the variant payload. It works well for me. The struct can be changed without impacting matches (unless of course the match uses fields that change, but then you'll be changing that code no matter what).

1 Like

I think it depends on what we expect calling code to possible do in response to the error. In this example, most branches just contain a String, so what can be done except displaying to the user? What other conceivable ways to handle the error exist? If the only interesting distinction is whether the it makes sense to retry the request, how about an error like this:

pub struct Error {
    message: String,
    may_be_transient: bool
}

The string is for logging.
The enums are needed as different handling logic is executed based on what error happens.