If your API already maps to orthogonal sets of errors, or if it's in active development/iteration, you might not get much value from this. But good & specific error types are great documentation for helping developers understand "what can go wrong," and the effects compound with layers of abstraction.
it isn't always the case, of course, but it also isn't always NOT the case.
Maybe it would make sense to consider the API a function is presenting when making errors for it; if an error is related to an implementation detail, maybe it doesn't belong in the public API. If an error does relate to the public function's purpose (FileNotFound for a function that reads config), then it has a place there.
The point there is the error is not just an error in isolation, but it has an attached error category as well. And the error categories can compare errors from other categories for equivalence.
So for example, say you have an error which contains (status_404, http_result_category), then you can compare that instance with (no_such_file_or_directory, generic_category), and because http_result_category knows about generic_category, it can handle the comparison and say that these are equivalent[2].
This allows you to preserve the quite detailed errors while also using them to handle generic error conditions further up.
That said, doing this for every function... sounds tedious, so perhaps not worth it.
[1]: https://www.boost.org/doc/libs/1_88_0/libs/system/doc/html/s...
[2]: https://www.boost.org/doc/libs/1_88_0/libs/system/doc/html/s...
Though, I suppose with something broader like IOException the situation is different.
try {
open file
read some bytes
read some more bytes
}
makes sense, as they all relate to the same underlying resource being in a good state or not.This means that each function only cares about its own error, and how to generate it. And doesn’t require macros. Just thiserror.
You would most likely have had to navigate up and down the caller chain regardless of how you scope errors.
At least this way the compiler tells you when you forgot to handle a new error case, and where.
Open sum types? I’m on the fence as to whether they should be inferrable.
The whole point (in my mind at least) of type safe errors is to know in advance all if the failure modes of a function. If you share an error enum across many functions, it no longer serves that purpose, as you have errors that exist in the type but are never returned by the function.
It would be nice if the syntax made it easier though. It's cumbersome to create new enums and implement Error for each of them.
For example, let's say the function frobnicate() writes to a file and might get all sorts of file errors (disk full, no permissions, etc.). It seems like those have to be wrapped or embedded, as the article suggests.
But then you can't use the "?" macro, I think? Because wrapping or embedding requires constructing a new error value from the original one. Every single downstream error coming from places like the standard library has to go through a transformation with map_err() or similar.
It is the most common approach, hence, status quo.
> I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted.
I like your approach, and think it's a lot better (including for the reasons described in this article). Sadly, there's still very few of us taking this approach.
I do too! I've been debating whether I should update SNAFU's philosophy page [1] to mention this explicitly, and I think your comment is the one that put me over the edge for "yes" (along with a few child comments). Right now, it simply says "error types that are scoped to that module", but I no longer think that's strong enough.
[1]: https://docs.rs/snafu/latest/snafu/guide/philosophy/index.ht...
Your answers on stack overflow and your crates have helped me so much. Thank you!
I feel like structural typing or anonymous sum types would solve this problem without macros.
I mean given A = X | Y and B = X | Y | Z, surely the compiler can tell that every A is a B?
That being said, I think a limited version of the feature that disallows the the same type with divergent lifetimes or even all generics would still be useful and well liked.
Especially attribute style macros which apply to variants of an enum allow a lot of code expansion which reduces boilerplate a lot.
Rust probably should have had a set of standard error traits one could specialize, but Rust is not a good language for what's really an object hierarchy.
Error handling came late to Rust. It was years before "?" and "anyhow". "Result" was a really good idea, but "Error" doesn't do enough.
let mut x = 1
x = false
is x a usize? a bool? a usize | bool? let mut x = if some_condition { 1 } else { false }
is x x a usize? a bool? a usize | bool?One could make inference rules about never inserting unions without explicit intervention of user types. But then you get into (IMO) some messy things downstream of Rust's pervasive "everything is an expression" philosophy.
This is less of a problem in Typescript because you are, generally, much less likely to have conditionally typed expressions. There's a ternary operator, but things like `switch` are a statement.
So in production code Typescript, when presented with branching, will have a downstream explicit type to unify on. Not so much in Rust's extensive type chaining IMO. And then we start talking about Into/From and friends....
I don't really think that you want a rule like `(if cond { x: T } else { y: U}) : T | U` in general. You'll end up with _so many_ false negatives and type errors at a distance. But if you _don't_ have that rule, then I don't know how easily your error type unification would work.
Cant you do something like let mut x: Result<Either<Foo, Bar>, Error> in Rust? Same thing, just more ceremony?
Just like.... if you infer the union then all your type errors are going to shift around and you'll have to do more hunting to figure out where your stuff is. And my impression is that Rust has a lot more expression inference going on in practice than TS. But just an impression.
let mut x = 1
x = false
In TS, x is inferred as usize, second line is an error. let mut x = if some_condition { 1 } else { false }
In TS, x is inferred as usize | bool.Is there something specific to rust that makes this less clear that I'm missing?
So inferring as an untagged union is not wrong of course! It's just that if you are always inferring the type of an if expression to A | B, then this will also happen unintentionally a lot.
And so at the end of some code, when you actually use x, then you'll see an error like "expected usize, got usize | bool". In the case that this was a mistake, you're now looking at having to manually figure out why x was inferred this way.
In typescript your "if expression" is a ternary expression. Those are quite rare. In rust they're all over the place. Match statements are the same thing. Imagine having a 10 clause match statement and one of them unintentionally gives a different type. There's even just the classic "semicolon makes the branch into a ()"!
So always inferring a union across branches of an if expression or a match means that your type errors on genuine mistakes are almost never in the right spot.
Of course we can annotate intermediate values to find our way back. Annotating intermediate values in a chained expression is a bit miserable, but it is what it is.
Decent typescript tends to not have this problem because there are few syntactic structures where you need to evaluate multiple branches to figure out the type of an expression. And the one big example (return values)... well you want to be annotating the return value of your functions in general.
Rust is in a similar space for Result types, at least. But I don't think it generalizes at all. If you start inferring union types, and combine that with trait resolution, I _think_ that we'd end up with much less helpful error messages in the case of actual mistakes, because the actual location of the error will be harder to find.
let mut x = if some_condition { 1 } else { false }
// bunch of code
return f(x) // expected usize, got usize | bool
TS gets away with this stuff because JS's object model is simple (TS doesn't need to do any form of trait resolution!) and the opportunities to introduce unions implicitly are relatively few in TS code in general.And this isn't even really getting into Rust needing to actually implement untagged unions if they had them! Implicit tagging feels off in a language very serious about not having expensive hidden abstractions. But how are you going to guarantee bit layout to allow for the differentiation here?
I'm saying all of this but I'd love it if someone showed up with a good untagged union proposal to Rust, because I _like_ the concept. Just feels intractable
type FooOrBarOrError = Foo | Bar | Error;
Then that could desugar to: enum FooOrBarOrError {
Foo(Foo),
Bar(Bar),
Error(Error),
}
And it could also implement From for you, so you can easily get a FooOrBarOrError from a Foo, Bar, or Error; as well as implementing Display, StdError, etc. if the components already implement them.I actually wonder if you could implement this as a proc macro...
Foo | Bar makes sense when Foo and Bar are logically similar and their primary difference is the difference in type. This is actually rather rare. One example would be a term in your favorite configuration markup language along the lines of JSON or YAML:
type Term = String | List<String>;
or perhaps a fancier recursive one: type Term = String | Box<List<Term>>;
or however you want to spell it. Here a Term is something in the language, and there are two kinds of terms: string or lists.But most of the time that I've wanted a sum type, I have a set of logical things that my type can represent, and each of those things has an associated type of the data they carry. Result types (success or error) are absolutely in that category. And doing this wrong can result in a mess. For example, if instead of Result, you have SuccessVal | Error, then the only way to distinguish success from error is to literally, or parametrically in a generic, spell out SuccessVal or Error. And there are nasty pathological cases, for example, what if you want a function that parses a string into an Error? You would want to write:
fn parse_error(input_string: &str) -> Error | Error
Whoops!Ok' and Err' as nominal type constructors which are unioned:
struct Ok<T>(T);
struct Err<E>(E);
fn parse_error(input_string: &str) -> Ok Error | Err (ErrorA | ErrorB | ErrorC...)
Or make a sum type enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_error(input_string: &str) -> Result<Error, (ErrorA | ErrorB | ErrorC...)>
The error types are unioned for easy composition but you have a top level sum type to differentiate between success and failure.Every Rust project starts by looking into 3rd party libraries for error handling and async runtimes.
If we just added what 95% of projects are using to the standard library then the async runtime would be tokio, and error handling would be thiserror for making error types and anyhow for error handling.
Your ability to go look for new 3rd party libraries, as well as this article's recommendations, are examples of how Rust's careful approach to standard library additions allows the ecosystem to innovate and try to come up with new and better solutions that might not be API compatible with the status quo
color-eyre is better than anyhow.
Too much innovation gets out of control, and might not be available every platform.
As a result, I find error handling in Go to be pretty cumbersome even though the language design has progressed to a point where it theoretically could be made much more ergonomic. You can imagine a world where instead of functions returning `(x, err)` they could return `Result[T]error` - an that would open up so many more monadic apis, similar to whats in Rust. But that future seems to be completely blocked off because of the error handling patterns that are now baked into the language.
There's no guarantee the Rust team would have landed on something particularly useful. Even the entire error trait, as released, is now deprecated. `thiserror`, the most popular error crate for libraries wasn't released until 2019.
True, almost. Not mine
Makes me sad
Excuse me what?
> This means, that a function will return an error enum, containing error variants that the function cannot even produce.
The same problem happens with exceptions.
On no_std, I've been doing something like the author describes: Single enum error type; keeps things simple, without losing specificity, due the variants.
When I need to parse a utf-8 error or something, I use .map_err(|_| ...)
After reading the other comments in this thread, it sounds like I'm the target audience for `anyhow`, and I should use that instead.
Personally, I think I prefer thiserror style errors everywhere, but I can see some of the tradeoffs.
You use, too many, commas, in your, writing. It’s okay to have a long sentence with multiple phrases.
Thanks for sharing your thoughts.
This does add the “complexity” of there being places (crate boundaries in Rust) where you want types explicitly defined (so to infer types in one crate doesn’t require typechecking all its dependencies). TS can generate these types, and really ought to be able to check invariants on them like “no implicit any”.
Rust of course has difference constraints and hails more from Haskell’s heritage where the declared return types can impact runtime behavior instead. I find this makes Rust code harder to read unfortunately, and would avoid it if I could in Rust (it’s hard given the ecosystem and stdlib).
Which brings me to my other big gripe with Rust (and Go): the need to declare structs makes it really unwieldy to return many values (resorting to tuples, which make code more error prone and again harder to read).
I have some desire to make an RFC for limited cross-item inference within a single crate, but part of it wouldn't be needed with stabilized impl Trait in more positions. For public items I don't think the language will ever allow it, not only due to technical concerns (not wanting global inference causing compile times to explode) but also language design concerns (inferred return types would be a very big footgun around API stability for crate owners).
The author is a fan of Asterix I see :)
I don't know what the solution is. And Rust is definitely a lot better than C++ or Go. But it also hasn't hit the secret sauce final solution imho.
You have to anyway.
The return type isn't to define what error variants the function can return. We already have something for that, it's called the function body. If we only wanted to specify the variants that could be returned, we wouldn't need to specify anything at all: the compiler could work it out.
No. The point of the function signature is the interface for the calling function. If that function sees an error type with foo and bar and baz variants, it should have code paths for all of them.
It's not right to say that the function cannot produce them, only that it doesn't currently produce them.
Similarly, JavaScript seems to do OK, but I miss error levels. And C seems to also have OK error conventions that aren't too bad. There's a handful of them, and they're pretty uncontroversial.
Macros seem to be wrong in every language they're used, because people can't help themselves.
It's like a red flag that the language designers were OK giving you enough rope to hang yourself with, but also actively encourage you to kill yourself because why else would you use the rope for anything else?
I like it better then python and go.
For example,
enum ConfigError {
Io(io::Error),
Parse { line: usize, col: usize },
...
}You could argue it would be better to have a ParserError type and wrap that, and I absolutely might do that too, but they are roughly the same and that's the point. Move the abstraction into their appropriate module as the complexity requests it.
Pretty much any error crate just makes this easier and helps implement quality `Display` and other standard traits for these types.
And you don't punish your compile times.
The macro for everything folks are making Rust slow. If we tire of repetition, I'd honestly prefer checked in code gen. At least we won't repeatedly pay the penalty.
No dogma. If you want an error per module that seems like a good way to start, but for complex cases where you want to break an error down more, we'll often have an error type per function/struct/trait.
jgilias•7h ago
I’m getting a bit of a macro fatigue in Rust. In my humble opinion the less “magic” you use in the codebase, the better. Error enums are fine. You can make them as fine-grained as makes sense in your codebase, and they end up representing a kind of an error tree. I much prefer this easy to grok way to what’s described in the article. I mean, there’s enough things to think about in the codebase, I don’t want to spend mental energy on thinking about a fancy way to represent errors.
Waterluvian•6h ago
Please correct me if I’m misunderstanding this, but something that surprised me about Rust was how there wasn’t a guaranteed “paper trail” for symbols found in a file. Like in TypeScript or Python, if I see “Foo” I should 100% expect to see “Foo” either defined or imported in that specific file. So I can always just “walk the paper trail” to understand where something comes from.
Or I think there was also a concept of a preamble import? Where just by importing it, built-ins and/or other things would gain additional associated functions or whatnot.
In general I just really don’t like the “magic” of things being within scope or added to other things in a manner that it’s not obvious.
(I’d love to learn that I’m just doing it wrong and none of this is actually how it works in Rust)
jgilias•6h ago
itishappy•6h ago
Waterluvian•6h ago
So maybe what I’m remembering about Rust was just seeing a possible but bad convention that’s not really used much.
57473m3n7Fur7h3•6h ago
Still generally prefer the plain non-macro declarations for structs and enums though because I can easily read them at a glance, unlike when “go to definition” brings me to some macro thing.
burnt-resistor•6h ago
Declarative macros (macro_rules) should be used to straightforwardly reduce repetitive, boilerplate code generation and making complex, messy things simpler.
Procedural macros (proc_macro) allow creating arbitrary, "unhygienic" code that declarative macros forbid and also custom derive macros and such.
But it all breaks down when use of a library depends too much on magic code generation that cannot be inspected. And now we're back to dynamic language (Ruby/Python/JS) land with opaque, tinkering-hostile codebases that have baked-in complexity and side-effects.
Use magic where appropriate, but not too much of it, is often the balance that's needed.
quotemstr•6h ago
Overuse of macros is a symptom of missing language capabilities.
My biggest disappointment in Rust (and probably my least popular opinion) is how Rust botched error handling. I think non-local flow control (i.e. exceptions) with automated causal chaining (like Python) is a good language design point and I think Rust departed from this good design point prematurely in a way that's damaged the language in unfixable ways.
IOW, Rust should have had _only_ panics, and panic objects should have had rich contextual information, just like Java and Python. There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic. Ever.
God, I hope at least yeet gets in.
zbentley•5h ago
It could have gone that way, but that would have “fattened” the runtime and overhead of many operations, making rust unsuitable for some low-overhead-needed contexts that it chose to target as use-cases. More directly: debug and stack info being tracked on each frame has a cost (as it does in Java and many others). So does reassembling that info by taking out locks and probing around the stack to reassemble a stack trace (C++). Whether you agree with Rust’s decision to try to serve those low-overhead niches or not, that (as I understand it) is a big part of the reason for why errors work the way they do.
> There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic.
I sometimes think that I’d really love “nopanic”. Then I consider everything that could panic (e.g. allocating) and I like it less. I think that heavy use of such a feature would lead to people just giving up and calling abort() in library code in order to be nopanic-compatible, which is an objectively worse outcome than what we have today.
quotemstr•5h ago
So add an option not to collect the debugging information. The core exception mechanism remains.
> Whether you agree with Rust’s decision to try to serve those low-overhead niches or no
It's not a matter of Rust choosing to serve those niches or not. It's the language designers not adequately considering ways to have exceptions and serve these niches. There's no contradiction: it's just when Rust was being designed, it was _fashionable_ to eschew exceptions.
> Then I consider everything that could panic (e.g. allocating) and I like it less. I think that heavy use of such a feature would lead to people just giving up and calling abort() in library code in order to be nopanic-compatible,
Huh? We don't see people write "noexcept" everywhere in C++ to be noexcept-compatible or something. Nopanic is for cleanup code or other code that needs to be infallible. Why would most code need to be infallible? I mean, panic in Drop is already very bad, so Rust people know how to write infallible code. The no-failure property deserves a syntactic marker.
dontlaugh•4h ago
Rust got errors right, with the possible exception of stdlib Error types.
quotemstr•3h ago
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p07...
Your post is a fantastic example of the problem I'm talking about: you're conflating a concept with one implementation of the concept and throwing away the whole concept.
Language design and implementation are different things, and as an industry, we used to understand that.
o11c•1h ago
throwaway894345•6h ago