Errors in Rust can now be handled more ergonomically, cleanly, and simply: introducing a new error crate

I'm not great with macros but I feel like it might get tricky if you have something like

create_error!(CombinedError: SomeError(Err1, Err2), AnotherError(Err1, Err2));

Maybe I am just underestimating the power of macros due to lack of experience though. I think in many cases, the nesting probably isn't a big problem as long as the errors implement Error::source.

On another note, I wonder if it would be worth it to add a version that boxes all the variants to avoid increasing the size of the result, create_error_boxed! or whatever.

I can imagine the name turning people off; I've hated error handling in every language I've used that has exceptions, including Java. This is something I disagree strongly with the OP on, though I think the crate looks good. cex is also a lot more complex so it's harder to get a handle on, whereas polyerror is super simple. I'm still not fully sure I "get" cex, explaining what it actually looks like after macro expansion in the docs like in the OP might help. In any case I think they both have interesting ideas for error handling.

1 Like

What is with drive to make Rust errors look like exceptions in C++. Java and other languages?

I came to Rust, in some part, to get away from that.

2 Likes

It would be helpful if your post was clearer about what specifically you didn't like about it, and how this library would make things more error-prone than doing things without it. Since it doesn't change the core semantics of rust error handling, I don't see the problem.

Polyerr (what this thread is nominally about) isn't about "making Rust errors look like exceptions." All polyerr does is make it easier to make lightweight product types for the purpose of error bags.

Cex implements a proc macro to give throws syntax and more exception-like types, but is not (nominally) what this thread is about.

I'm not sure I'm talking about a specific "it" here. There have been a lot of "it's" mentioned here.

The opening post speaks of "...about Rust's error system and its shortcomings compared to corresponding systems other languages, like Java..."

Which sounds very much like someone missing exceptions.

Later I read:
public Object readAndParseFile(Path file) throws IOException, ParseError{

Hmm... sounds suspiciously like someone else misses exceptions. It even says "throw" and "IOException".

And hence my question. What is a Rust noob (relatively) supposed to think?

As it happens I do not relish the idea of cluttering up my function definitions with anything like:
``
create_error!(pub ParseThenCombineError: ParseBoolError, ParseIntError);

Rust functions can easily get lost in all kind of other syntactic noise already.
1 Like

It sounds to me like someone who likes the existing semantics of rust error handling and wants to keep those, but who wants to reduce the syntax boilerplate.

I feel like "exceptions" are often treated like a boogeyman in Rust discussions. But something like Object readAndParseFile(Path file) throws IOException, ParseError is already expressing something very similar to fn readAndParseFile(file: Path) -> Result<Object, IoOrParseError>. Certainly if we had anonymous enums, a wish that's come up many times, it'd be almost exactly -> Result<Object, IoError | ParseError>.

So to me it's like "OO" -- something that represents too many different parts to be a useful term in a discussion. And thus it's important to isolate a specific property that one likes about the existing way that a new proposal would change -- say if it made propagation invisible, with no textual marker that an error can happen in a call. (Of course, that's not what this library does, just an example.)

Said otherwise, saying that something smells like exceptions gets a "yes, that's the point" response, and ignored, and thus doesn't contribute to the discussion. But a more specific concern has a chance of being addressed. Note how the OP even mentions how "many useful objections were raised, such as that treating errors as data has advantages" and that lead to the new design.

9 Likes

There's a flip side to this, which is that when you open with "this part of Rust is not enough like Java" in a Rust forum, some people are going to say "yes, that's the point" and ignore the rest of what you say. Perhaps especially when the part in question is error handling, given how strongly people feel about exceptions. (I know polyerr isn't about exceptions, but when you say "Java error handling", that's what comes to mind.)

Or to be constructive: the OP is probably compelling to those coming from Java, but consider making a summary of the project that starts and ends from a Rust perspective.

5 Likes

I'm not just targeting those who are coming from Java — Instead, I think that Java's error handling is better insofar as it allows more specific return types, so I'm implementing that one aspect of its system in Rust. I think/hope that this is an improvement for (mostly) everyone, not just those who come from Java.

1 Like

Yeah, I get that, and I do think Polyerror looks interesting and addresses a real use case. But the OP referred to Java a lot, mostly with praise, dumped on the popular Anyhow (which is solving a different problem), and even had a Java code example before the Rust one. For anyone who's not a Java fan, there's a lot to get past.

It's just my opinion and you can take it or leave it, but I think it may be wiser to just start with the "Polyerror works by making it so trivial..." paragraph (when addressing a Rust-based audience).

3 Likes

I'd use a slightly different wording like "error expression / documentation", instead. "Error handling in Java" will just cause bad memories to re-surface.

1 Like

I think references to Java could be dropped completely without hurting the pitch at all. Java error handling makes me think of, among other things, the following which have nothing to do with the actual crate:

  • automatic propagation unless explicitly caught - In Rust terms, every function call that retuns a Result has an invisible ? at the end, making it so you don't have to explicitly handle fallible calls that are added later as long as their error type is already covered by the function signature
  • unchecked exceptions - In Rust terms, special error types that don't have to be added to the signature and can thus pop up from basically any line of code. They auto-propagate regardless of any function signatures, eventually panicking when propagated in main unless explicitly handled
  • try-catch blocks - though Rust try-blocks are in development (and I welcome them), I think the catch block is ugly and redundant in Rust

What the crate really does is make it easy to make specific error enums that only contain the variants a given function actually returns. I think many Rust users will be familiar with the error-type-per-crate strategy and explaining why this is an improvement doesn't require bringing in Java error handling that is associated with lots of other things.

7 Likes

I'd like to ask some questions which I feel are worth further discussion.

Are errors flat or potentially hierarchical?

Example of flat errors

create_error!(pub FooErr: ParseIntErr, ParseBoolErr);
pub fn foo() -> Result<(),FooErr> { todo!() }

create_error!(pub BarErr: ParseIntErr, ParseBoolErr, ParseFloatErr);
pub fn bar() -> Result<(),BarErr> {
    foo()?;
    todo!()
}

Example of hierarchical errors

create_error!(pub FooErr: ParseIntErr, ParseBoolErr);
pub fn foo() -> Result<(),FooErr> { todo!() }

create_error!(pub BarErr: FooErr, ParseFloatErr);
pub fn bar() -> Result<(),BarErr> {
    foo()?;
    todo!()
}

Will pollyerror provide mechanism to help generating "impl From"s for flat errors?

Suppose pollyerror adopted the "flat errors" policy.

pub fn bar() -> Result<(),BarErr> {
    foo()?; // need converting FooErr to BarErr
    todo!()
}

Generally speaking, we need to construct an enum from another one where all the variants of the latter are included in the former's.

How to handle "hierarchical errors" for downstream users?

Suppose pollyerror adopted the "potentially hierarchical errors" policy.

It is possible for low-level errors to bubble up to high level APIs, resulting a complicated tree structure in the final error enum.

Example of handle hierarchical error

create_error!(pub BazErr: FooErr, BarErr);
pub fn baz() -> Result<(),BazErr> {
    foo()?;
    bar()?;
    todo!()
}

fn downstream() {
    baz().map_err( |err| match err {
        BazErr::FooErr( foo_err ) => match foo_err {
            FooErr::ParseIntErr( int_err ) => todo!(),
            FooErr::ParseBoolErr( bool_err ) => todo!(),
        },
        BazErr::BarErr( bar_err ) => match bar_err {
            BarErr::ParseIntErr( int_err ) => todo!(),
            BarErr::ParseBoolErr( bool_err ) => todo!(),
            BarErr::ParseFloatErr( float_err ) => todo!(),
        },
    });
}

This is a tree of height 3. I could not image what if the height was 6 or even more.

fwiw rust pattern syntax can represent these cases with a single match

use BazError::*;
match e {
    Bar(BarError::ParseIntError(int_err)) => todo!(),
    Foo(FooError::ParseIntError(int_err)) => todo!(),
    Bar(BarError::ParseBoolError(bool_err)) => todo!(),
    Foo(FooError::ParseBoolError(bool_err)) => todo!(),
    Bar(BarError::ParseFloatError(float_err)) => todo!(),
}

Or even

use BazError::*;
match e {
    Bar(BarError::ParseIntError(int_err)) | Foo(FooError::ParseIntError(int_err)) => todo!(),
    Bar(BarError::ParseBoolError(bool_err)) | Foo(FooError::ParseBoolError(bool_err)) => todo!(),
    Bar(BarError::ParseFloatError(float_err)) => todo!(),
}

Not really a huge improvement but yea

It means "ParseIntError is ParseIntError, no matter where it originates from, Foo or Bar." In this situation, we have no reason to use hierarchical errors.

Is this polyerror crate not somewhat similar to some-error? It was recently blogged about here: https://jam1.re/blog/anonymous-sum-types-for-rust-errors.

I might be wrong since I have not studied either deeply. But in general the use case is to generate a unique error enum per fallible function/method. A potential benefit of some-error is that it uses (emulates) "anonymous sum types" so you don't need to name all your errors. If you create one per function you will have a lot of them.

Looks similar to the approach I took in ertrace, sans the error return tracing.

This single-error-type-per-function approach is exactly the approach I took when implementing internal libraries for https://sneakysnake.io. It seems to be the only sane way to do errors for library.