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

"Breakage" because the user assumes something that isn't true is their fault.

1 Like

I think, we're on the same page, now and are moving towards a constructive discussion about the subtle differences between what it means to break stability, i.e. what is formally guaranteed vs. what is practically expected. Indeed, as a user, you can also fall into traps, that causes both compile- and runtime errors upon updating your dependencies, because you didn't expect something to happen, even if the dependency author did nothing wrong (let's assume, they know how to not mess up semver versioning, which is a big topic on its own).

I do think, Rust would heavily benefit from having both a library and binary development guide on how to prevent undesirable breakages by following some simple rules and most importantly, actually throw these guides at us, instead of hiding them somewhere in a dark corner. I expect Rust to have some kind of document/thread/issue, that establishes formal rules on what is considered a breaking change (they must use something for the standard library!?) and what isn't, yet I couldn't find it.

A few pitfalls for users:

  • Using * in use statements, except for designated preludes and exhaustive enums
  • Using extension traits for external (in relation to the trait) types and running into ambiguous method calls (this is by far the most brittle breaking change, that can occur, from my experience)

A few pitfalls for library authors:

  • Add new items to preludes
  • Too liberal dependency version requirements
  • Introducing cutting-edge standard library features (hopefully, we'll get MSRV – minimum supported rustc version – rather sooner than later)

However, at the end of the day, the one who adheres to the rules is the one in the right and in this particular case, adding a variant to a non-exhaustive enum is considered a non-breaking change and that's all we can count on. Nonetheless, it'd be a good idea for the author to document this, so that users don't just do naive stuff in the _-match arm, like using unreachable!(). Documentation is always beneficial to a library and I never noticed anyone complaining about too much documentation for some crate, on the forum.

I agree with almost all of what you said there.

To my original point (which got this entire sub-thread started), because #[non_exhaustive] can cause silent breakage and/or altered behavior, I simply recommended that that be possible to use, yet not on-by-default i.e. that it is opt-in. Which I think is not dissimilar to how it works if it is written by hand.

4 Likes

There should be a warning when you are missing some variants of a non-exhaustive enum (probably opt-in per match). I remember this being mentioned a long time ago on the tracking issue, but I don't think there's been any movement on implementing the lint for it.

2 Likes

I've thought that something like this would be nice. Something I wish was possible was conversion from one error type into another when the target error contains all of the first one's variants:

polyerror::create_error!(SomeError: Err1, Err2);
fn do_stuff() -> Result<(), SomeError> {
    Ok(())
}

polyerror::create_error!(CombinedError: Err1, Err2, Err3);
fn do_other_stuff() -> Result<(), CombinedError> {
    do_stuff()?;
    Ok(())
}

Not sure if there's a way to implement this without it being too cumbersome for the user of the library. I'd be curious to hear if you have any thoughts on a feature like this.

1 Like

Cex author here.

The cex crate simulates of checked exceptions in Rust, which can do this in the following way.

In Cargo.toml:

enumx = "0.4"
cex = "0.5"
use enumx::export::*;
use enumx::predefined::*;
use cex::*;

#[cex]
fn do_stuff() -> Result!( () throws Err1, Err2 ) {
    ret!(());
}

#[cex]
fn do_other_stuff() -> Result!( () throws Err1, Err2, Err3 ) {
    do_stuff()?;
    ret!(());
}

The polyerror crate chooses the "error-per-function" strategy, which is adopted by some previous version of cex. The code above could be written as:

cex! {
    fn do_stuff() -> ()
        throws Foo(Err1)
             , Bar(Err2)
    {
        Ok(())
    }
}

cex! {
    fn do_other_stuff() -> ()
        throws Foo(Err1)
             , Bar(Err2)
             , Baz(Err3)
    {
        do_stuff()?;
        Ok(())
    }
}

The cex!{} macro will produce enum do_stuff::Err and enum do_other_stuff::Err for do_stuff() and do_other_stuff().

But I changed my mind and gave up the "one-error-per-function" strategy in newer versions of cex. Because I noticed a fact that in a library, series of functions may throw the same set of errors, so "one-error-per-function" strategy is only used to track the source of errors which can be done by cex's "logging" mechanism.

Now, the cex library supports even more functionality.

  1. You can call do_other_stuff() inside do_stuff() while propagating errors is still convenient. Notice that the former throws less errors than the latter.

    #[cex]
    fn do_stuff() -> Result!( () throws Err1, Err2 ) {
        do_other_stuff()
        .or_else( |err| #[ty_pat(gen_throws)] match err {
            Err3(_) => /* deal with this error */,
        })
    }
    
    #[cex]
    fn do_other_stuff() -> Result!( () throws Err1, Err2, Err3 ) {
        ret!(());
    }
    
  2. Checked exceptions can fallback as impl std::error::Error or any other traits.

    #[cex]
    fn do_stuff() -> Result!( () throws Err1, Err2 ) {
        ret!(());
    }
    
    fn downstream_api() -> Result<(), impl std::error::Error> {
        Ok( do_stuff()? )
    }
    

@john01dav I'm sorry to introduce cex in your crate's announcement, but many people seems to hate checked exceptions and did not want to discuss in my previous post.

2 Likes

it might be possible to generate the correct from impl for other types to help with the conversion, not sure if this will pass coherence checks

impl<T> From<SomeError> for T
where
    T: From<Err1>,
    T: From<Err2>,
{ ... }

actually no, this probably overlaps with From<T> for T, darn

At least one way to do that would be to implement Into<Err1> and Into<Err2> for SomeError, I think. The rustdoc for Into says that it's bad practice to implement it directly and one should implement From when possible, so I haven't looked into it in depth, but it seems like it may work.

A story of mine:

  1. started with impl From for concrete types.

  2. not satisfied with impl-per-function, began to solve impl From for generic enums.

  3. blocked by "impl overlapping" issue and "phantom generic parameter" came to rescue.

  4. traits with phantom generic parameter e.g. ExchangeFrom, took the place of std::convert::From.

  5. finally, complained that no phantom generic parameter in std::ops::Try.

i think you'd only be able to impl TryInto for those types, since it's an enum and could be one of the other variants.

I feel like the better route might be to tweek the syntax of the macro to let you pass in more information about the other error types

create_error!(SomeError: Err1, Err2);

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

Kinda like you're using a destructuring pattern of SomeError when defining CombinedError. Then you should have enough info to also impl From SomeError for CombinedError.

1 Like

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.

3 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.
2 Likes

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