The Second Great Error Model Convergence
69 points by carlana
69 points by carlana
Love this. I especially love the admission that checked exceptions are back.
There's an aspect of propagation that I think is missing from this piece.
In Rust, we have the ? operator, but that so rarely acts as a satisfactory error chaining device that can give you breadcrumbs to where something has actually happened, thus we have things like the context mechanisms in anyhow, and our Result<_, E> values have to transform E into Box<dyn Error> somewhere along the way. Python has raise ... from, and Go has fmt.Errorf. Each of these can be used to provide nicely-formated breadcrumbs to help an operator learn about errors, but each of them has their quirks.
anyhow is type-erasing, so you have downcast_ref to check if a given error matches some type, but if you want to inspect if any error in the whole chain has a given type, that's an exercize left to the reader. anyhow tends to not be used in libraries since it would appear in the library's signature and force the library's consumers to directly (not merely transitively) depend on anyhow, so people tend to use thiserror instead. Again, inspecting chains tends to be fairly tedious, especially if you have N distinct error types that can be caused by 1 concrete error type somewhere along the way.
Python's raise ... from construct can provide a chain not just of values, but of stack traces, which is nice, I suppose, but you're still dealing with exceptions, the pile of stack traces if very noisy, and the except clause has no facility for unwrapping exceptions wrapped this way or inspecting the chain, so inspecting the chain is an exercise left to the reader.
Go's fmt.Errorf does a good job with wrapping, and errors.Is does a good job of inspecting a chain for a matching value. errors.As is a rather cumbersome way for inspecting a chain for a given type, and is sensitive to the distinction between T and *T so can easily lead to finicky distinctions that aren't meaningfully checked by the compiler.
Blindly calling if err != nil { return err } in Go and blindly using Rust's ? are the most convenient thing for the caller, which is why people do them, but very often leave an operator scratching their head as to how an error occurred.
After programming Rust and Go and Python all in production, (and being on call for things made by other engineers in all three languages), I'm left liking Go's error system the most, and Python's the least, with Rust being in the middle. I strongly dislike stack traces as the means of understanding where an error has occurred, so I'm glad that it seems that newer languages are largely eschewing this as the default.
Dislike stack traces? I don't understand how an opaque error value is any better. Pretty much Rust and Go required 3rd-party libraries to make error handling actually useful, and are essentially a poorer implementation of exceptions. Good exception design allows you to bundle values into the exceptions, allows nesting exceptions, and a stack trace gives you the entire callstack to follow... and then bundle that with structured logging...
Stack traces are the wrong artifact in a concurrent environment where the cause might have moved across multiple workers before ending up at the handling site.
Why do you dislike Python"s handling? I find it pretty useful to have the stack traces to not only see the current exception but where every call happens and where it originates. It is incredibly noisy though.
I generally think Python's error handling gets you most of the way there on lots of stuff for "normal" code. The difficulty with stack traces come when you interact with frameworks that call into your code.
So your framework calls obj.foo() (where obj is some instance of Obj, a user-called class), futzes around with it, and then does bar(result_of_foo) way off in the distance.
You end up with a stack trace for the call to bar, but are very far away from the original source of your issue.
Now if you just ported this example as-is to Rust-y like things, you would have the same issue. But because Rust doesn't have the stack trace stuff (especially when you consider libs that don't assume panic = 'unwind'!), libs will instead be Result-focused.
And because of that, a call like bar(result_of_foo) failing will likely also build up a decent error path that might also provide the context of result_of_foo. Far from a guarantee, but the promise of Result is you are pushed towards those kinds of error-handling patterns
exception raising in Python is much more "well raise an Exception and you'll have something" or "catch an Exception and you might be able to recover from it". And that works alright in many case.
I guess I'm too deep into Python but I find that the general error reporting is very good! What I don't like is having to handle exceptions in multiple layers of the code, it has the same flavor of annoyance as dealing with sync vs async code. Once code throws, you have to catch exceptions everywhere. But that's inevitable, at least in Python, and exceptions give a lot of context in general which I find useful, more often than not.
Nice article - specifically, this is a good thing to state explicitly:
Second, there’s a separate, distinct mechanism that is invoked in case of a detectable bug.
- In Java, index out of bounds or null pointer dereference (examples of programming errors) use the same language machinery as operational errors.
- Rust, Go, Swift, and Zig use a separate panic path. ...
- Operational error of a lower layer can be classified as a programming error by the layer above, so there’s generally a mechanism to escalate an erroneous result value to a panic.
This is a distinction we realized is better in YSH ... we don't want to conflate "operational errors" and "programming errors"
Or I sometimes call them "bad data" versus "bad code" -- e.g. "bad data" is "JSON failed to parse", and "bad code" is mytypo(x)
Python does mix these two up:
And JavaScript does too
I call them "failures" and "mistakes" respectively, it's a very important distinction, especially between what can/should be caught and potentially retried, and what (usually) shouldn't.
I think this is overstating the convergence; none of the traits described applies to all the “modern” languages listed. In particular I think it’s being overly kind to Go (whose error handling I hate with a passion.)
functions that can fail are annotated at the call side
Not in Go. I guess the claimed annotation is that one of the returned values is named “err”, but that's just a convention. I could write a, b := something() and b might be an Error but you can’t tell. If a function returns just an Error I could forget to assign or check the return value. And of course there’s no requirement that the caller actually check the err value.
Third, results of fallible computation are first-class values
Again, not in Go (nor Swift, as acknowledged.) A function can return (int, error) but that's not a type in Go, it’s just multiple return values, and it has to be destructured at the call site.
I do wish Kotlin were part of this convergence; that's another casualty of its dependence on the JVM. (I know about Kotlin Native; I’m talking about Kotlin's language design being hamstrung by features the JVM can support, such as the limitation of value classes having only a single field.)
I could write a, b := something() and b might be an Error but you can’t tell. If a function returns just an Error I could forget to assign or check the return value. And of course there’s no requirement that the caller actually check the err value.
I think this is not very generous to Go. I dislike the Go flavor generally but Rust and Go both rely on linting tools yelling at you that you didn't check the error case (Almost all the Rust bugs I experience with third part Rust code is people writing unwrap all over the place in places without any foundation for the unwrap to be even mostly true).
If you think Rust's model is halfway decent on the facts you brought up, I think Go gets mostly there. Ergonomics are, of course, another topic....
Rust errors out the compiler while Go doesn’t even warn; they are not the same.
I don't think we're talking about the same thing here.
let some_result = do_falliable_thing();
use_result(some_result.unwrap());
^ this is code you see quite often in production Rust code in practice. Compiler is fine with it.
Banning unwrap is an option, and that's a linting option.
Similary in Go:
some_result := do_falliable_thing()
use_result(some_result)
^ above won't work because you're returning an error + thing tuple, right?
so the equivalent is like
result, err := do_falliable_thing()
use_result(result)
so now it's like... err is unused right? So some linter will flag that.
But maybe you write:
result, _ := do_falliable_thing()
use_result(result)
I'm not sure if that last one will be caught by any useful go linter (disclaimer: I'm assuming such a linter exists. Maybe it doesn't??????)
It's not 1000% the same but to me the middle Go example and the Rust example are pretty close! They're both bits of "bad" code that linters will catch, but the language itself will allow through
Please see my other comment wrt. the middle Go example.
My personal experience is of unwrap being banned in production Rust code. But I have no doubt people are out there, doing the thing. I don't think a compiler should complain about .unwrap() or _ := failable() because both are the programmer explicitly opting into certain behaviour.
That said, .unwrap() isn't like any of those examples; rather, it is:
err := falliable()
if (err != nil) {
panic(err)
}
And failing fast is very different than ignoring an error.
And failing fast is very different than ignoring an error.
Yeah on this my experience using third party rust code is people often using unwrap to ignore. In a sense it's still failing fast but well... often it feels like it could have been recoverable in cases I hit. At least use expect with a little error message!!!
I get what you're saying about Go for the case where there's not a used return value, there Rusts warn on unused infra really shines in a way I think most linters would struggle with
In what sense isn't it failing fast? .unwrap panics on failure. Unless a panic_hander is defined, the end result is either an unwind or abort.
IMHO it's too easy to silently ignore an error Go. It's also too easy to panic when encountering an error in Rust. But the two behaviours are deeply and completely different! Go silently continues in an unexpected state; Rust loudly prevents continuing in an unexpected state.
Here's an example of the Go compiler erroring out: https://go.dev/play/p/Z0ztJdkBzgp
Of course, this doesn't catch 100% of bugs. But it does catch a fair number of unused errors, so it's not quite right to say that it doesn't even warn.
Given func foo() (int, err):
x, err := foo() errors if we don't use err (your example)x := foo() errors because we only destructure one return valuefoo() is fine!Which, to be fair, Rust only warns about that one one.
The situation I had in my mind was the IMHO very common case of a series of function calls that can return err: https://go.dev/play/p/BlKCVDYiSLd
In this case, not handling err from the second call won't trigger the compiler error because the idiom results in err being shadowed and the compiler isn't smart enough to differentiate.
Yep, agreed. The only point I'd make is that in your example, the second err is not shadowed; it is reassigned.
If it were shadowed like in https://go.dev/play/p/K6CziRJW6-f then it would still have the same unused variable warning.
It’s possible that returning a Result value will be more common in Java with the advent of sealed interfaces, switch expressions, and records. For some of my personal projects I’ve made domain specific result sum types because it’s much more syntactically ergonomic than it used to be.
It's funny because it depending on the situation, I find myself at either extremes. In Python, I really want business logic to throw exception I can even group them and handle them later and I'm fine with try-except-finally. But when dealing with interfaces that use error codes, like REST APIs, I really want to get error codes and not raise on error codes. Why would I want to do exception handling when I already know what the error code is?! After some reflection, the kind of error handling you want to do, might be a function of how much context you need to fix the error vs how acceptable stopping the world to fix the exception is.
To me it's so obvious that the checked approach is the correct one that I could never understand the pushback. Maybe there was something particular to Java and other languages with similar types systems that made it awkward to work with checked exceptions (a lack of proper polymorphism, perhaps)? Or maybe my usage is so idiosyncratic that I didn't realise others didn't like it?
Since I'm a strongly typed functional programmer I want to make invalid behaviours unrepresentable, but since I'm a pragmatic engineer I don't want to pay a high conceptual cost for that. My Haskell effect system Bluefin lets me get the best of both worlds, for example this function takes a Bool and returns an Int or can throw A, B or C as an exception:
foo ::
Exception A e ->
Exception B e ->
Exception C e ->
Bool ->
Eff e Int
One common way of describing this is that the Exception ex e arguments are "capabilities": they give foo the capability to throw an exception of type ex. Because they're simply function arguments (not things bolted on to the return type) it's trivial to compose functions that throw exceptions.
But suppose we look at it from the point of view of Anders Hejlsberg in the article linked from the OP. What if we want foo to throw an exception of type D too? Well, that would be a new argument, so pays exactly the same cost as adding any other new argument to a function and we ask ourselves the same question: why? Sometimes there's a good reason and we have to do it; sometimes there not and we hold ourselves back. But in either case it's just a normal question of program design, nothing to do with exceptions per se.
And if we think A, B and C is too many exceptions to keep track of? Well, again, it's a normal question of program design: do we want to bundle those three up into a product/record type/struct? We can if we want. They're just arguments! We can do this, and pass just one argument:
foo2 ::
(Exception A e, Exception B e, Exception C e) ->
Bool ->
Eff e Int
Maybe you think that's cheating because there are still three different exceptions. OK, if we don't care about the difference between them, just convert them all to String
simplerFoo ::
Exception String e ->
Bool ->
Eff e Int
simplerFoo ex b =
handle (throw ex . show) $ \exa -> do
handle (throw ex . show) $ \exb -> do
handle (throw ex . show) $ \exc -> do
foo exa exb exc b
That's the equivalent of what Anders Hejlsberg derides as "throws Exception That just completely defeats the feature". I don't think so! If you really don't want to distinguish between different sorts of exceptions just throw an error string that you can print or log. Don't bother trying to keep track of fine-grained error states that you're not going to distinguish in the end anyway.
See also https://hackage.haskell.org/package/bluefin/docs/Bluefin-Exception.html
The thing with error handling is that it is actually a hard, subtle and situation specific problem. On a surface level it seems simple and largely just an inconvenience. Developers like to think about the happy path, and happy path is the most common path, but then... real life happens and error handling turns out very important. So programming culture for decades has been bouncing around looking for some silver bullet global-optimum, which turned out not to be possible.
At least in Rust the error handling has been done in a synthesis-like (thesis - error values, anthitesis - exceptions) middle ground, open-ended way. The weakness of exceptions being implicit and thus easy to overlook was avoided, while the tediousness of error handling was attempted to be avoided with a very compact short-circuit operator. Error values can be both: enumerated or type-erased, also as a middle ground. The downside of all of this is that now the developer need to make somewhat nuanced and informed decision about the details of their error handling.
So my take would be that it's not things are converging, but that the industry learned and is no longer as naive about the problem, and the approaches taken are more subtle in their differences, and by necessity being "more in the middle".
A different take, which I happened across yesterday:
I've worked extensively in large codebases that use checked exceptions, unchecked exceptions, and Result types [...] The error handling story was essentially the same in all of these codebases: Errors could occur nearly anywhere [...] This experience was the most pleasant in the codebase that used unchecked exceptions.
https://www.reddit.com/r/programming/comments/1pvjtcb/comment/nw08ye9/
I prefer a world with both. Things like a transient network error, I'd prefer to handle via exceptions; things like a lookup in a container that can fail, a Result-style return value (or Try-functions with out parameters). The latter makes sense for lightweight error handling. The former makes sense for heavier error handling and stack traces built in.
Trying to shoehorn everything into either one results in compromises and poor ergonomics.
You can see this in API evolution in language ecosystems like OCaml and C# where certain operations used to throw, but now have APIs returning an optional, or boolean return with out params.