Stream: project-error-handling

Topic: Better enums


view this post on Zulip alex (Sep 19 2020 at 02:22):

Often, I'll see libraries that have many different errors that could be raised, they'll often place them inside of one single enum that every function returns. It would be nice if you could specify that a function returns only a subset of an enum

view this post on Zulip BatmanAoD (Kyle Strand) (Sep 19 2020 at 02:35):

I think that would be especially useful if different functions return overlapping subsets of the set of possible errors, since that can't be modeled with nested enums.

view this post on Zulip XAMPPRocky (Sep 19 2020 at 06:04):

I think large error enums are an anti-pattern overall as it requires people to handle error cases that could be impossible to trigger. In my opinion, error enums should be as small and isolated as much as possible.

That would also require a significant language changes and this is a libs team project group.

view this post on Zulip Joshua Nelson (Sep 19 2020 at 13:15):

I think large error enums are an anti-pattern overall as it requires people to handle error cases that could be impossible to trigger.

/me glares at https://docs.rs/toml/0.5.6/toml/ser/fn.to_string.html

view this post on Zulip Joshua Nelson (Sep 19 2020 at 13:15):

err ... actually I might have just misread that page, it looks like that's specific to serialization errors

view this post on Zulip Jake Goulding (Sep 19 2020 at 13:33):

Right now, SNAFU encourages creating enums with some light recommendation to make multiple error types. One per module, for example, but also more. There's a ergonomic tradeoff between many variants that people have to handle vs many error types people have to handle.

I'm working on something like this for SNAFU at the moment:

#[derive(Debug, Snafu)]
struct Error1;
#[derive(Debug, Snafu)]
struct Error2;
#[derive(Debug, Snafu)]
struct Error3;

#[derive(Debug, Snafu)]
enum ErrorA { Error1(Error1), Error2(Error2) }

#[derive(Debug, Snafu)]
enum ErrorB { Error3(Error3), Error2(Error2) }

fn f1() -> Result<(), Error1> {}
fn f2() -> Result<(), Error2> {}
fn f3() -> Result<(), Error3> {}

fn fa() -> Result<(), ErrorA> {
    f1()?;
    f2()?;
}

fn fb() -> Result<(), ErrorB> {
    f3()?;
    f2()?;
}

view this post on Zulip Jake Goulding (Sep 19 2020 at 13:34):

I'll probably also need to figure out some way of going from subset enums to superset enums.

view this post on Zulip Jake Goulding (Sep 19 2020 at 13:36):

It would be nice if you could specify that a function returns only a subset of an enum

As stated, that requires a language change. I'm seeking to achieve that goal by making creating the errors, enums, and conversions easier.

view this post on Zulip Jake Goulding (Sep 19 2020 at 13:36):

However, think of a library where every function returns a unique error type. I think you'd hate it.

view this post on Zulip Joshua Nelson (Sep 19 2020 at 13:36):

Jake Goulding said:

However, think of a library where every function returns a unique error type. I think you'd hate it.

you'd have to use an error handling library that has a single error type

view this post on Zulip Joshua Nelson (Sep 19 2020 at 13:37):

(or Box<dyn Error> everywhere)

view this post on Zulip Seán Kelleher (Sep 19 2020 at 14:32):

Jake Goulding said:

However, think of a library where every function returns a unique error type. I think you'd hate it.

This actually sounds appealing to me, and from my brief experience Rust seems to allow consuming such libraries with low overhead. Do you have more thoughts on why it would be undesirable? Perhaps because it'd be difficult/impossible to write catch-all handler functions?

view this post on Zulip Jake Goulding (Sep 19 2020 at 14:54):

As soon as you call two functions with different error types and want to return either, you need to unify them somehow. That means either boxing them or creating a new type (usually an enum). SNAFU is all about creating those enums easier, but it’s still an amount of work you have to do.

view this post on Zulip Jake Goulding (Sep 19 2020 at 14:54):

So I guess my objection is that it’s not actually low overhead.

view this post on Zulip Jake Goulding (Sep 19 2020 at 14:58):

Even with a language-based solution, you’d still have to type all the specific variants in the signature (and then have all the downsides of a unnameable type)

view this post on Zulip Jake Goulding (Sep 19 2020 at 15:05):

There’s also a decision about who takes on the overhead. If the library does it, then every user can benefit at the risk of it not being a perfect fit. If the library punts on it, then every user has to re-create similar work

view this post on Zulip oliver (Sep 19 2020 at 15:13):

For you what are the downsides of unnameable types?

view this post on Zulip Jake Goulding (Sep 19 2020 at 15:29):

That you can’t name it :-)

You can’t document it, You can’t reuse them without introducing duplication.

view this post on Zulip oliver (Sep 19 2020 at 15:35):

tl;dr is it a undocumented feature or a side effect of the type system?

view this post on Zulip oliver (Sep 19 2020 at 15:38):

there just doesn't seem to be a whole lot of attention given to it overall

view this post on Zulip Jake Goulding (Sep 19 2020 at 15:44):

I’m not sure exactly what you are referring to. Right now there’s no language feature for anonymous enums, which is what I think the initial intent was.

There are anonymous types around traits, which is mostly what I’m basing the downsides around.

view this post on Zulip oliver (Sep 19 2020 at 15:54):

From what I can gather at a glance unnameable types are a problem which work has gone into to mitigate and anonymous enums are a proposed feature

view this post on Zulip oliver (Sep 19 2020 at 16:07):

This might also be related to opaque types?

view this post on Zulip Charles Lew (Sep 19 2020 at 17:42):

I feel that, it's not that useful to enum all kinds of errors. It would be more useful if we can classify error into how we want to deal with them. Basically there's three strategies: Abort, Retry, Ignore. If you already decided to abort, what caused it is not at all very useful... It's in what situations you should retry or ignore is very useful.

view this post on Zulip Jake Goulding (Sep 19 2020 at 18:08):

oliver said:

From what I can gather at a glance unnameable types are a problem which work has gone into to mitigate and anonymous enums are a proposed feature

Maybe? Rust 1.0 had unnamable types (every closure), then Rust 1.26 added impl Trait syntax to allow semi-naming them, at least their shape. Work continues on to make these more useful.

view this post on Zulip Jake Goulding (Sep 19 2020 at 18:09):

Charles Lew said:

I feel that, it's not that useful to enum all kinds of errors. It would be more useful if we can classify error into how we want to deal with them.

This assumes that there's a single such grouping. What categorization is "failing to open a file"?

view this post on Zulip Markus Unterwaditzer (Sep 19 2020 at 18:15):

Jake Goulding said:

However, think of a library where every function returns a unique error type. I think you'd hate it.

you could possibly paper over the effects of this by doing something like this:

// in library
#[derive(From, Into)]   // imagine this is from derive_more or w/e
enum AllErrors {
    Error1(Error1),
    Error2(Error2)
}

// in app
enum AppError {
    LibraryError(AllErrors)
}

impl<T> From<T> for AppError where T: Into<AllErrors> {
    fn from(e: T) -> AppError {
        AppError::LibraryError(e.into())
    }
}

unfortunately this would instantly conflict with other trait impls, such as Impl Into<AllErrors> for AppError... need specialization again? or perhaps the library exports a macro that explicitly impls From for all its errors for a given type.

view this post on Zulip oliver (Sep 19 2020 at 18:35):

Charles Lew said:

It would be more useful if we can classify error into how we want to deal with them.

Some errors may also result in UB where others do not.

view this post on Zulip Jake Goulding (Sep 19 2020 at 18:40):

@Markus Unterwaditzer I think that would run into https://stackoverflow.com/q/37347311/155423 quickly. However, the enum that you've described is more-or-less exactly what SNAFU does. With current syntax:

#[derive(Debug, Snafu)]
enum AllErrors {
    Error1 { source: Error1 },
    Error2 { source: Error2 },
}

view this post on Zulip Jake Goulding (Sep 19 2020 at 18:41):

oliver said:

Some errors may also result in UB where others do not.

That sounds (a) interesting (b) hard-to-believe and (c) like a separate topic. Perhaps you'd care to start a new topic describing how that could happen?

view this post on Zulip oliver (Sep 19 2020 at 18:42):

Not especially if it isn't toally germane to the current discussion

view this post on Zulip isHavvy (Sep 19 2020 at 21:05):

Jake Goulding said:

However, think of a library where every function returns a unique error type. I think you'd hate it.

Is that any worse than each Iterator function returning its own iterator type?

view this post on Zulip Jake Goulding (Sep 19 2020 at 21:22):

It certainly feels different. Other than itertools, I don’t know of a library where every function returns an iterator. 😅

view this post on Zulip Jake Goulding (Sep 19 2020 at 21:23):

There’s also a matter of composition. Iterators tend to compose with less hassle.

view this post on Zulip Jake Goulding (Sep 19 2020 at 21:24):

Although I think improving error-bearing iterators could also be something the project looks at and potentially improves

view this post on Zulip Jake Goulding (Sep 19 2020 at 21:25):

Although a different topic as well.

view this post on Zulip Jake Goulding (Sep 19 2020 at 21:30):

@alex circling back to your original point, why do you wish for such a feature? You gave a potential implementation, but glossed over the benefit you are seeking.

view this post on Zulip Jake Goulding (Sep 19 2020 at 22:11):

Iterators tend to compose with less hassle.

You can get easy composition of errors by boxing them always, but then you lose the ability to easily test for a specific error.

Iterators also tend to wrap their underlying iterator via generics. That’s something that errors could do (and SNAFU supports it) but it’s pretty unusual.

view this post on Zulip Jake Goulding (Sep 19 2020 at 22:12):

fn foo() -> Result<(), impl Error> would likewise be surprising.

view this post on Zulip BatmanAoD (Kyle Strand) (Sep 19 2020 at 22:16):

I'm a bit confused about the composition-without-boxing issue. Isn't that trivial? ? automatically calls into() if necessary, and the conversion from one enum into a "superset" enum should be very simple (in fact it could even be a no-op in most cases).

view this post on Zulip simulacrum (Sep 19 2020 at 22:22):

@BatmanAoD (Kyle Strand) I think the point is that the superset enum doesn't exist unless the library provides it

view this post on Zulip simulacrum (Sep 19 2020 at 22:22):

and if you do this "right" you then presumably need tons of such enums

view this post on Zulip simulacrum (Sep 19 2020 at 22:22):

for every pair/three/... distinct small error enums/structs in your library

view this post on Zulip simulacrum (Sep 19 2020 at 22:23):

and at that point you really just want language support. One thing I've thought about historically is that if we had "enum from impl Trait" or similar, we could do something like Result<(), impl Any + Error>, but it's ... not perfect, perhaps

view this post on Zulip simulacrum (Sep 19 2020 at 22:24):

though I guess error already supports downcasting, so you don't need the Any

view this post on Zulip BatmanAoD (Kyle Strand) (Sep 19 2020 at 22:24):

Right, but that's a different problem than composability, I think. But yes, that's a ton of code.

view this post on Zulip simulacrum (Sep 19 2020 at 22:25):

I think the problem is that easily composable errors lead you to big error enums (or erased errors), both of which mean that you can't easily know "okay, I've handled all cases that this function can return"

view this post on Zulip simulacrum (Sep 19 2020 at 22:26):

kind of like how e.g. std::io::copy between two vectors basically can't fail, but the compiler can't catch it for you today because it just returns io::Result

view this post on Zulip Jake Goulding (Sep 19 2020 at 22:26):

Pedantically, it calls into unconditionally :innocent:

The non-trivial point I see is the creation of those enums. The language doesn’t create them for you. SNAFU can help, but people seem to prefer the boxing path, presumably because of the simplicity of use.

How do you see those enums being defined / created? An interesting exercise would be 3 leaf errors and then all 6 permutations of those errors (half demonstrated above)

view this post on Zulip Jake Goulding (Sep 19 2020 at 22:27):

Or @simulacrum could say the same thing but better while I’m typing. 😴

view this post on Zulip simulacrum (Sep 19 2020 at 22:28):

It's certainly an interesting (and hard!) problem to solve. fine-grained composability, especially with semver-promises, seems both fragile and perhaps too similar to checked exceptions from Java to my liking... but it may also be not that bad, if you can have the compiler take care of it for you. I think my major complaint with checked exceptions in Java was always repeating myself in the callstack

view this post on Zulip Jason Smith (Sep 19 2020 at 23:41):

The use pattern for errors is that you generally care about one or two types and you pass the rest to the default handler (up the stack). I've seen entire libraries written to throw Exception (a general type of error) in Java because there are just too many exception cases propagating around. This is similar to the Anyhow solution, which might not be ideal, but it gets the job done without loss of sanity. :) It seems like there should be something better in a language known for its strong typing, though.

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:37):

@simulacrum for the repeating bit do you mean writing errors out in the signature of leaf functions and then having to write those errors in every function that calls those?

view this post on Zulip simulacrum (Sep 20 2020 at 01:38):

Yep, though I don't know of a good solution

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:38):

I'm not super familiar with checked exceptions

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:38):

I'm wondering if somehow associated types could help with this

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:38):

like if you could create an associated error type for a function

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:38):

then use an anon enum to define it, and then you could name that associated type

view this post on Zulip Jane Lusby (Sep 20 2020 at 01:39):

I feel like unioning the different error types would still be an issue tho

view this post on Zulip simulacrum (Sep 20 2020 at 01:40):

Hm, perhaps. I think the challenge is that you probably want semver guarantees for the list (at least that it won't grow, shrinking seems fine), but to do that well you really do want to enumerate the list, at least in public functions

view this post on Zulip simulacrum (Sep 20 2020 at 01:42):

But I personally haven't written ~any Rust libraries with error types, so maybe this is not too much of a problem in practice. Certainly I can't think of cases where I sort of wanted this

view this post on Zulip simulacrum (Sep 20 2020 at 01:42):

(I guess for e.g. serde it would be cool to have infallible serialization, but that seems hard and not worth it)

view this post on Zulip simulacrum (Sep 20 2020 at 01:44):

I think most of the time when I've felt an API returning a smaller set would be good, that wouldn't be practical because the API is a trait or equivalent to that, and so having the error type be more specific isn't practical

view this post on Zulip Charles Lew (Sep 20 2020 at 02:03):

Jake Goulding said:

Charles Lew said:
This assumes that there's a single such grouping. What categorization is "failing to open a file"?

I think callee define all kinds of errors, and caller define the categorization.

view this post on Zulip XAMPPRocky (Sep 20 2020 at 06:29):

@simulacrum Slightly off topic but serde does have infallible serialisation since the error type is an associated type of Serializer. So you could just use a unit struct for that particular case.

view this post on Zulip isHavvy (Sep 20 2020 at 07:18):

Straw man syntax:

enum LibraryError {
    E1, E2, E3,
}

fn foo() -> Result<(), FooError> { Err(LibraryError::E3) }

subenum FooError = LibraryError::{E2, E3};

view this post on Zulip isHavvy (Sep 20 2020 at 07:25):

We would extend as to allow casting from a subenum to its parent enum (or parent subenum should we allow subenums of subenums)

view this post on Zulip Lokathor (Sep 20 2020 at 07:33):

subenum should probably just auto-coerce into the parent type as necessary.

view this post on Zulip isHavvy (Sep 20 2020 at 07:38):

That can work. The main thing I think that should stand out about one is that you can match over FooError and not have to worry about LibraryError::E1. Could even possibly allow associated items on FooError.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 09:53):

I'd like to submit the approach that I've been using briefly, but have been contemplating for a while. For reference, a lot of this approach can be seen in the first commit for a small new project I'm working on.

I currently prefer the approach of simply having a unique error enum for each function that describes the all the different failures that that function can generate. This has the negative of being more work for the function author, but has the most flexibility in my opinion.

An example is the "leaf" method parse_deps, which can return a ParseDepsError, defined as follows:

enum ParseDepsError {
    DupDepName(usize, String, usize),
    InvalidDepName(usize, String, usize),
    InvalidDepSpec(usize, String),
    UnknownTool(usize, String, String),
}

(For this, I think that record syntax would provide much better readibility and I intend to refactor ParseDepsError to use it; I'll leave this aspect of the discussion to the side for now.)

In terms of composability, I think that the best approach is for consuming functions to simply nest inner errors in their own error enums. For example, parse_deps is called by parse_deps_conf, which has the following error enum:

enum ParseDepsConfError {
    MissingOutputDir,
    ParseDepsFailed(ParseDepsError),
}

It adds its own extra failure condition, but in the case of a sub-failure, it simply tags it and nests it. A handler then has the choice of drilling into the error to get more information, if they want.

To cater for slightly easier error-handling I also have a wrap_err! macro, similar in nature to try!:

macro_rules! wrap_err {
    ($x:expr, $y:path $(, $z:expr)* $(,)?) => {{
        match $x {
            Ok(v) => {
                v
            },
            Err(e) => {
                return Err($y(e $(, $z)*));
            },
        }
    }}
}

I'll give an example usage. install can return an InstallError, and one of its possible values is CreateMainOutputDirFailed(IoError, PathBuf). This allows for the following call:

wrap_err!(
    fs::create_dir_all(&conf.output_dir),
    InstallError::CreateMainOutputDirFailed,
    conf.output_dir,
);

This, like try!, returns the Ok value of the call on success. On Err(err), however, the function will return CreateMainOutputDirFailed(err, conf.output_dir), which nests the error and adds some contextual information, for both logical and debugging purposes. From my understanding, "wrapping" the error in such a way is trickier with try! because of the following:

The big caveat of wrap_err!, in my opinion, is its blockiness. My personal code style has some accountability here, but looking at the definition of install, you can probably see how wrap_err obscures the "happy path" logic somewhat. I think this could probably be remedied somewhat by adding wrap_err! support to Result, so that the following could be done:

fs::create_dir_all(&conf.output_dir)
    .wrap_err!(
        InstallError::CreateMainOutputDirFailed,
        conf.output_dir,
    );

But that's more of a personal improvement idea, rather than a suggestion.

One benefit of the above approach, in my opinion, is to be able to define a comprehensive error handler at the top level which can give very specific error messages. Even better, in my opinion, is that the error messages themselves are defined at the top level, where they belong, as opposed to in the generating functions, as you might find in say, Go error handling, for example. Again, it might not be to everyone's liking, but here is the top-level error handling routine that I mention. Note also that such a routine is much more amenable to say, localisation, and other processing, in my opinion.

Sorry for the wall of text; like I mentioned, these are ideas that have been germinating for a while and I'm excited to suggest. Let me know what you think! Thanks.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 09:54):

As a side note, I also adopted the following convention in the above project:

This project also identifies two types of error value. An error that contains a nested error is considered a "failed operation" error. Such error values should end with Failed and the nested error(s) should be the first listed in the associated tuple data. For example:

enum InstallError<E> {
    GetCurrentDirFailed(IoError),
    ...
}

Any other error is considered a "root" error, and has no required naming or data conventions. For example:

enum InstallError<E> {
    ...
    NoDepsFileFound,
    ...
}

enum ParseDepsError {
    InvalidDependencySpec(usize, String),
    UnknownTool(usize, String, String),
}

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:04):

hi @Seán Kelleher, some thoughts on your suggestions:

the wrap_err function you suggested sounds a lot like map_err, is there a reason you didn't use that? alternatively it's very similar to the context function on SNAFU.

regarding the print_install_error, is there a reason you didn't implement the error trait and use that to print your errors? I have a great deal of interest in this specific problem myself and wonder what you think of error reporting crates like eyre and anyhow that are built ontop of the error trait and why you choose to implement a manual printing method specific to your error type rather than using error trait based composition to print your errors generically.

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:23):

@Seán Kelleher yes, it sounds like you have 90-95% overlap with the goals and current implementation of SNAFU

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:24):

fs::create_dir_all(&conf.output_dir)
    .context(CreateMainOutputDirFailed { dir: conf.output_dir })?

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:25):

#[derive(Debug, Snafu)]
enum ParseDepsError {
    #[snafu(display("This is the text {}, {}", alpha, gamma))]
    DupDepName { alpha: usize, beta: String, gamma: usize },

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:27):

much more amenable to say, localisation

Yep. My hope is to combine Fluent somehow https://github.com/projectfluent/fluent-rs/issues/107

view this post on Zulip Seán Kelleher (Sep 20 2020 at 11:33):

Hi Jane and Jake,

Thanks very much for the questions. For the most part, I must admit I don't have much familiarity with Rust, and so a lot of my approach is a halfway-house between my personal ideal and whatever tools make themselves apparent to me at the time. As such, I wasn't actually aware of map_err, but at a cursory glance the main difference I can see is that wrap_err! is able to return the resulting error, but with map_err that would need to be done in a following statement. Other than that I think map_err looks almost ideal, if it can be used like the following:

fs::create_dir_all(&conf.output_dir)
    .map_err(|err| { InstallError::CreateMainOutputDirFailed(err, conf.output_dir) });

Looking at SNAFU, it looks like the context issue is handled quite nicely with it.

I'm not fully sure about your question about implementing the error trait, I might have to take a look at eyre and anyhow to compare the approaches; it could very well be that they achieve the same thing. If it's in terms of having the error be able to describe itself, I think that's useful to have, but I think that it's ultimately the application should render errors to the user manually, and relying on error's own descriptions can present errors in terms that are too low-level; this would be how things work in Go anyway, which is my primary language. Sorry if I've misunderstood though.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 11:34):

Jake Goulding said:

fs::create_dir_all(&conf.output_dir)
    .context(CreateMainOutputDirFailed { dir: conf.output_dir })?

I actually haven't looked into SNAFU yet, but this looks ideal.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 11:36):

Ah, I just realised that map_err can/should be used with ? (I only checked the docs for usage); that actually works better than wrap_err in that case so.

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:46):

but I think that it's ultimately the application should render errors to the user manually

I think you'll ultimately find this to be difficult. For that to be fully realized, every error exposed by every library would have to give complete access to all the variants. This makes it much harder for a library to adhere to semver, and is probably downright impossible given platform-specific concerns.

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:46):

but within a specific crate, sure.

view this post on Zulip Jake Goulding (Sep 20 2020 at 11:48):

about implementing the error trait,

Note that SNAFU also defines the Error trait for you.

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:52):

the way it normally works is you describe your errors with the error trait and then you define how to display them with a reporter

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:53):

I actually did a whole talk on this if you want a nice primer

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:53):

https://youtu.be/rAF8mLI0naQ

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:53):

I even mention how this relates to go error handling briefly

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:54):

tho admittedly I know very little about go

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:55):

this doesn't really help with localization, in that case it's up to the author of the crate to provide localization support rather than the author of the app

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:55):

but this means that there's much less duplicated work, you don't end up with every crate that depends on your crate having to reimplement localization and error messages

view this post on Zulip Charles Ellis O'Riley Jr. (Sep 20 2020 at 11:56):

Thanks for the primer Jane.

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:56):

it's all tradeoffs, tho I doubt many people would want to adopt an error handling style where they have to define the error messages for foreign error types

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:56):

people already hate how verbose it is to just implement Error Display and From manually

view this post on Zulip Jane Lusby (Sep 20 2020 at 11:56):

and that's just for the local error types

view this post on Zulip DPC (Sep 20 2020 at 12:11):

would be nice to have #[derive(Error)] some point in the future :stuck_out_tongue:

view this post on Zulip Jake Goulding (Sep 20 2020 at 12:22):

DPC said:

would be nice to have #[derive(Error)] some point in the future :P

I doubt it, just because Error: Display and deriving Display isn't really a _thing_.

view this post on Zulip DPC (Sep 20 2020 at 12:25):

i'm aware hence "some point in future" :stuck_out_tongue:

view this post on Zulip Jake Goulding (Sep 20 2020 at 12:41):

Jane Lusby said:

the way it normally works is you describe your errors with the error trait and then you define how to display them with a reporter

I mildly object to "normally", unless you call unwrap/expect/println a "reporter". Perhaps that's how it should be though.

view this post on Zulip Jake Goulding (Sep 20 2020 at 12:43):

@Seán Kelleher another point is that you can define Error and Display for your error types but still match on them to do whatever you want, like display them differently

view this post on Zulip Jake Goulding (Sep 20 2020 at 12:44):

SNAFU provides simple automatic implementations of Display, so you can use those if you mostly want to ignore Display anyway

view this post on Zulip Seán Kelleher (Sep 20 2020 at 13:11):

Jake Goulding said:

but I think that it's ultimately the application should render errors to the user manually

I think you'll ultimately find this to be difficult. For that to be fully realized, every error exposed by every library would have to give complete access to all the variants. This makes it much harder for a library to adhere to semver, and is probably downright impossible given platform-specific concerns.

It's a good point in terms of practicality, and perhaps it's just a pipe dream. However, in terms of semver, I'm not fully sure how this could be addressed without having each function return Error. For example, I would expect that the idea of specifying subsets of Error for specific functions runs into the same issues with semver.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 13:11):

Jane Lusby said:

I actually did a whole talk on this if you want a nice primer

That's great, thanks for linking, I'll take a look at this later.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 13:16):

Jane Lusby said:

it's all tradeoffs, tho I doubt many people would want to adopt an error handling style where they have to define the error messages for foreign error types

I suppose this is the crux of the matter, and I would admit that my approach is a theoretical purist one (at least in my opinion), and wouldn't necessarily suit everyone's tastes. Thankfully, the different options I've seen so far seem fairly orthogonal, and I'm very happy with what I've been able to get with Rust out of the box.

view this post on Zulip Jake Goulding (Sep 20 2020 at 14:54):

Seán Kelleher said:

I'm not fully sure how this could be addressed without having each function return Error. For example, I would expect that the idea of specifying subsets of Error for specific functions runs into the same issues with semver.

The general problem is that a public enum has public variants with public fields. That means that removing variants or fields is a semver break. If you haven't used #[nonexhaustive], then adding variants or fields is likewise a semver break. I'd expect any equivalent "anonymous enum" idea would have the same problem.

This ultimately means that things that should be innocuous refactorings because semver breaks. For example, if I had code like:

pub enum Error {
    NotFound { source: regex::Error },
}

but then realized I could replace the regex with a str::starts_with, I can't remove the error without a semver break. If that was my only usage of the regex crate, I can't even remove the (theoretically internal) dependency for the same reason.

view this post on Zulip Jake Goulding (Sep 20 2020 at 14:55):

In SNAFU, I encourage people to have two layers — public opaque error types and internal fully-detailed error types.

view this post on Zulip Jake Goulding (Sep 20 2020 at 14:56):

That way a crate exposes just mycrate::Error (or a small number of similar) that the author has to very carefully decide what to make public API.

view this post on Zulip oliver (Sep 20 2020 at 15:41):

Seán Kelleher said:

Thankfully, the different options I've seen so far seem fairly orthogonal...

Meaning different as a positive? Or meant to imply parallels to your proposal?

view this post on Zulip oliver (Sep 20 2020 at 15:43):

Which parts specifically?

view this post on Zulip oliver (Sep 20 2020 at 15:45):

Is the parts about nesting enums not viable?

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:26):

Jake Goulding said:

Seán Kelleher another point is that you can define Error and Display for your error types but still match on them to do whatever you want, like display them differently

That's the thing, and one of the very nice nice aspects of Rust, is that these are all orthogonal concerns, and Rust seems to handle them all very nicely. I suppose my main objective here is to present my own thoughts and approach, and how I would see errors being treated in an ideal world.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:39):

Jake Goulding said:

Seán Kelleher said:

I'm not fully sure how this could be addressed without having each function return Error. For example, I would expect that the idea of specifying subsets of Error for specific functions runs into the same issues with semver.

The general problem is that a public enum has public variants with public fields. That means that removing variants or fields is a semver break. If you haven't used #[nonexhaustive], then adding variants or fields is likewise a semver break. I'd expect any equivalent "anonymous enum" idea would have the same problem.

This ultimately means that things that should be innocuous refactorings because semver breaks. For example, if I had code like:

pub enum Error {
    NotFound { source: regex::Error },
}

but then realized I could replace the regex with a str::starts_with, I can't remove the error without a semver break. If that was my only usage of the regex crate, I can't even remove the (theoretically internal) dependency for the same reason.

Definitely, it's absolutely a tradeoff. Moreover, where semver usually allows, say, adding new enum values in the same major version, because of the Rust's exhaustive matching, this will also cause a break. I suppose it all boils down to compromise, either of theoretical ideals, or of practicality. For example, with the Error::NotFound example, you could still keep the enum value, even if it's not being used anymore, if you decide that it's more important to you not to do a version bump.

Note that I think the same issue exists with a "global-union"-type Error; if you do a refactoring where the Error::NotFound value just isn't returned anymore, you'll similarly be faced with the choice of a semver bump or unused value. The difference is in how often these issues are likely to occur (more often with function-specific errors, and less often with Error, probably).

view this post on Zulip oliver (Sep 20 2020 at 16:40):

Seán Kelleher said:

Jake Goulding said:

Seán Kelleher another point is that you can define Error and Display for your error types but still match on them to do whatever you want, like display them differently

That's the thing, and one of the very nice nice aspects of Rust, is that these are all orthogonal concerns, and Rust seems to handle them all very nicely. I suppose my main objective here is to present my own thoughts and approach, and how I would see errors being treated in an ideal world.

I think I lost the thread of the discussion somewhere. You contributed some very
interesting options and it was pointed out that SNAFU is similar in some of
its designs. Is most everything related to SNAFU and your designs now to be
considered untenable to the project goals? My tl;dr here of that is simply that
it's generally too difficult to trace error logic manually with a sufficient
level of specificity such that the concept of nested errors is out of scope.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:40):

Jake Goulding said:

That way a crate exposes just mycrate::Error (or a small number of similar) that the author has to very carefully decide what to make public API.

That's a very interesting approach; I'm not sure that I'd completely agree with it right now but it's definitely something I'm going to keep in mind as a possibility.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:43):

Sorry @oliver , I've been jumping around a bit throughout the day while I tried to answer on mobile, I'll try and address your comments now.

view this post on Zulip Jake Goulding (Sep 20 2020 at 16:53):

Note that I think the same issue exists with a "global-union"-type Error; if you do a refactoring where the Error::NotFound value just isn't returned anymore,

Likewise, it's a semver-incompatible change to go from returning an error to not and vice-versa. At least going away from an error has an easy enough hack where you say enum Error {} and it becomes zero-sized.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:55):

oliver said:

I think I lost the thread of the discussion somewhere. You contributed some very
interesting options and it was pointed out that SNAFU is similar in some of
its designs. Is most everything related to SNAFU and your designs now to be
considered untenable to the project goals? My tl;dr here of that is simply that
it's generally too difficult to trace error logic manually with a sufficient
level of specificity such that the concept of nested errors is out of scope.

From my understanding so far, it looks like most of what I want to be able to achieve with my own peculiar approach to error handling seems possible with SNAFU, even if I don't take some of the recommended approaches. The two main approaches that I don't see myself taking are to use Error types (I will endeavour to use function-level errors until I feel its infeasible), and to define rendering rules for error values on the error values themselves. However, due to the flexibility of Rust and SNAFU, it looks like I'm free to make those choices, with essentially no consequences. For example, I'm free to use nested errors in my projects as much as I want, and even if a crate exports errors with rendering rules specified on the error values, I believe it's feasible to ignore them and to specify a manual print function, if I so wish. This second point is greatly contrasted with the Go way of doing things, where you often depend on the error string returned from the lower level (unless the package authors are interested in exposing error values that can be handled properly).

On your TL;DR, this might be the case, but I suppose I'm just not yet convinced that it is too difficult. Of course, I'm not going to be pushing my approach on anyone, precisely because I don't have enough evidence to back it up.

view this post on Zulip Seán Kelleher (Sep 20 2020 at 16:59):

Jake Goulding said:

Likewise, it's a semver-incompatible change to go from returning an error to not and vice-versa. At least going away from an error has an easy enough hack where you say enum Error {} and it becomes zero-sized.

That's very cool. As for Error vs function-errors in practical terms, for semver it probably boils down to the fact that you're likely going to encounter the same issues with both at some point, but the Error approach is likely to defer those issues for a lot longer. What do you think?

view this post on Zulip oliver (Sep 20 2020 at 17:08):

Improving enums for multiple failure cases is specifically in-scope for the
project. I'm not clear on exactly how simple that can or should be. What's
exciting is the potential for something that really pushes the limits of some
fundamental trade offs.

In terms of semvar, is it to mean that the change requires an edition update
(i.e. it's a major breaking change)? Or is one just saying that packages
adopting the (non-Rust-breaking) feature will then choose to break their own
reverse dependencies?

view this post on Zulip Jake Goulding (Sep 20 2020 at 17:22):

oliver said:

Improving enums for multiple failure cases is specifically in-scope for the project.

I suppose it depends on exactly what that means. I don't know for sure, but I didn't expect there to be any language-level changes from the group. At best it might identify some patterns or desires and then request / propose them to the lang team.

What's exciting is the potential for something that really pushes the limits of some fundamental trade offs.

One of the best parts of Rust, IMO.

the change requires an edition update

So long as you mean the generally-accepted "edition" (e.g. the 2015 and 2018 editions), then what I have described is not that.

packages adopting the (non-Rust-breaking) feature will then choose to break their own reverse dependencies?

The public API of a crate is subject to semver concerns. That a function returns a Result or not is part of the public API. The specific error type in a Result is part of the public API. Public fields of an error type are part of the public API. Enum variants and fields are part of the public API. Changing any of those (without very specific conditions) constitutes a semver-incompatible change to the crate and thus a new release with a semver-distinct version.


Last updated: Jan 26 2022 at 13:32 UTC