The state of error handling in Go at the moment is embarrassing at best.
The state of error handling in Go at the moment is embarrassing at best.
type Result[T] struct { ok: *T err: error }
Great, so how do you work with it?
* You can have a `ok()` getter that returns `*T`. Now you you need an `if x != nil`
* `isOk()` + `isErr()`
* `unwrap() T`, which panics on errors
* `split() (*T, err)` that splits into separate values, especially awkward since you both need `if err != nil` AND dereference the pointer
That API is more awkward then the status quo, and doesn't buy you any correctness guarantees because eventually you have to do an `if x := res.ok(); x != nil` or `x, err := res.split(); if err != nil {` anyway.
Pretty much the only convenience you gain are functions like `map()` or `unwrap_or`, but eventually you always have to take out the value and without being able to pattern match you can't get an API that improves correctness much.
(std::optional in C++ is a great example)
Emphasis on eventually instead of at every single function call.
- map / map_err
- and_then / or_else
- unwrap / unwrap_or
And countless of other functions making it very practical to chain computations without having to pattern match anything.I do the same in Erlang/Elixir.
In Golang, I need to check every function call, and if I want to know where an error come from, I need to wrap it in an errors.New() because no exceptions = no stacktrace
Zig manages to provide traces with very little overhead:
https://ziglang.org/documentation/master/#Error-Return-Trace...
I am baffled as to why error handling in Go remains so impoverished.
> I cannot wait for the Result monad.
Generics make this possible, and will be a huge improvement to Go's error handling.
I'm not saying it will solve everything, but it's a huge step nonetheless.
Convenience is not a goal of the language, generally.
Most people who use Go have specifically chosen to use it though (or at least sought out opportunities, given that it's not something entrenched like Java being used due to inertia), so yes, most of them are fine with how Go is or they wouldn't be using it in the first place. If Go had generics from the start, I'd imagine most users would be fine with that too due to self-selection.
In Haskell, you can have functions that work generically over option-types, error-able types, lists, functions, tuples, etc.
In Rust, you have to specifically implement functionality for all of these.
But eg your 'map' function in Rust still works for all lists, no matter what item type. In Go before this change, you had to write a different map function for each item type that could be in your list.
In Haskell, the same 'map' function works for lists, functions, error-able types etc.
However, doing this kind of ... sucks.
See eg 'maybe' (https://www.stackage.org/haddock/lts-18.18/base-4.14.3.0/Pre... on stackage) or foldr for lists.
'foldr' is interesting, because it encapsulates a recursive pattern matching on lists. For the non-recursive version, see 'uncons' composed with 'maybe'.
[1] https://github.com/SeaQL/sea-orm/blob/64c54f8ad603df0c1d9da8...
Unwrap would look like:
file := os.Open("foo").Unwrap()
I'll take that over the current go state of the art: file, err := os.Open("foo")
if err != nil {
panic(err)
}
Just like "panic(err)" is used infrequently, "unwrap" would be used infrequently. They're comparable, and for the cases where unwrap is okay (test code, once-off scripts, etc), I'd definitely prefer it to the panic boilerplate.In Haskell, 'Maybe (Maybe Int)' is a different type from 'Maybe Int'.
That means that when you use eg a hash table that returns some kind null value like 'Nothing' on lookup when a key is not found, you can still stick exactly that kind of null value as a normal value into the table and everything will turn out fine.
Not to mention that traditional exception handling advice I've been handed down from the gray beards is to always handle exceptions as early as possible, which is exactly what go forces you to do with their approach.
that's similar to what Java does with the Optional type, not great, but not bad either
the alternative is checking for nulls which is worse in any possibile way
I usually implement something like Kotlin Result when I have to code in Java
with a couple of static helpers to build the result: Result.success(T) Result.failure(Throwable t)
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/
also Result in Rust has been an inspiration
Thanks to the pipeline operator and pattern matching, it makes pretty easy to read pipelines. It does not completely replace the with statement (that was not the point) but it simplified a lot of code.
Elixir is my daily drive and working in Java made me miserable until I started working around the lack of pattern matching facilities
Error handling is the worst part, I think a simple
switch (retval) {
Ok(val):
...
break;
Error(err):
...
would make Java much more pleasant, even without full pattern matching everywhere. sealed interface Result<T>
permits Ok, Error {}
record Ok<T>(value: T) implements Result<T> {}
record Error<T>(error: Throwable) implements Result<T> {}
// to consume
switch (result) {
case Error e -> e.error().getMessage(),
case Ok v -> v.value().toString()
}So to achieve the same exhaustiveness as a method like
User createUser() throws IOException, BusinessException
you would need both a Result-like construct and union types
Either<User, IOException | BusinessException> createuser()
which few mainstream languages offer. Otherwise you type is unable to express that only IOException and BusinessException are thrown and you don't need to handle a default Throwable case.
methods that declare checked exception can throw RuntimeException(s) so exhaustiveness cannot be totally enforced anyway
No. You can just feed it to the function (from a library / stdlib) that needs it, or call .fold() in the end.
Seems to me that the ones who write about "idiomatic go code" aren't the ones who are shipping softwares/libraries.
The majority of Go I've been hired to improve has suffered immensely from not using accepted idioms. Most of my work has been focused on introducing those idioms systemically.
People makes it a big deal, in reality it's not.
I rest my case.
However, the topic is errors, not exceptions. Those are very different concepts. Of what use is stack trace information in debugging values that you have assigned error meaning to when not other types of values?
If you had a function
func add(a, b int) int { return a * b }
there would be no expectation of carrying a stack trace to debug it. So what's different about func add(a, b int) error { return errors.New("cannot add") }
that does require a stack trace?Idiomatically, go uses errors for the purposes other languages use exceptions, so if this makes debugging harder, it's an important consideration.
However, the question was asking what is different about errors compared to other values. The add function above contains an error, yet I don't know of any language in existence where you would expect a stack trace bundled alongside the result to help you debug it. Why would that need change just because you decided to return a struct instead of an int? And actually, many APIs in the wild do represent errors as integers.
Python does. Ruby does. It's not just Java and JS. Go is very open about its approach being a departure.
> And actually, many APIs in the wild do represent errors as integers.
Many, many APIs in the wild are implemented in (or meant to be consumed from) C, which doesn't even have exceptions, so not using exceptions makes sense for them.
Very often idiomatic non-C host language wrappers for those APIs will fire exceptions when they get an error return.
Definitely not. Especially because early Ruby implementations brought huge overhead when exceptions were used, you were strongly advised to only use exceptions for actual exceptions. Ruby was one of the first languages that really started pushing the idea that exceptions should be reserved for exceptions, even if was just for technical reasons.
Those overhead problems have been addressed and are no longer a problem, but the sentiment has continued to ring true. I agree that doesn't stop people from trying to overload them, as I said earlier. But idiomatic? Not at all.
Using try/catch as intended is a bit of an art, but raise/rescue is everywhere. It is absolutely the primary way of handling errors. I think you might be confusing these two.
And it's awful. I use EAFP locally (to avoid TOCTOU and the like) at low level interfaces but I don't let it bubble up out of a function scope, because it is a goto in all but name.
I've also been increasingly using the `result` library/data structure. It's incredibly liberating to return an error as an object you can compose into other functions, vs try/catch, which does not compose.
Yes I write python almost like rust, and it's great. Strong types, interfaces, immutable types. It looks nothing like "old school python" but also behaves nothing like it. Gone are the day of "oh it crashed again, fix one line and rerun".
Exceptions should be for exceptional circumstances, not errors.
Edit: I see this is controversial. What do you take objection to? Making your python look less dynamic and more like rust? Try it before you knock it. Python's my favorite language, but I do not agree that many of the common "pythonic" patterns are good at scale.
An example: suppose you have a function that does some work with the filesystem, and also calls some user-supplied code. (Perhaps because the user can subclass something etc, or because you are getting a callback, the details don't matter.)
Naturally your function might have some idea how to handle its own filesystem trouble, but you have no clue how to handle any filesystem exceptions that come from user provided code.
It's rather awkward to get this exactly right.
The linked example shows a really basic sqlalchemy model lookup. What does spewing these new types all over my code get me that returning None or an empty dict/list doesn't without the overhead?
def find_user(user_id: int) -> Optional[User]:
user = User.objects.filter(id=user_id)
if user.exists():
return user[0]
else:
return None
Not only is this idiomatic, it conveys the same semantic meaning. I'm using an IDE, as is anyone else working in a large codebase. I'll be told at the point of invocation that find_user could return None and I need to possibly deal with that.No, it doesn’t. For a single computation like this, that pattern is roughly equivalent to Maybe (which contains no information about the case where there is no success result besides that it is absent) rather than Result (which has error information, kind of like an exception, but in the normal return path.)
For a series of computations, the composability of both Maybe and Result means that they are semantically richer.
Also, both Django and SQLAlchemy throw proper exceptions on bad queries or DB errors, which is probably the right thing to do in the average app using these libraries (the exception bubbles up, getting logged, returning the appropriate http error, etc).
I'm not crapping on this library, mind, I just can't find the use case that justifies it.
How do you compose a function that returns an int or an error with a function that takes an int as parameter?
Yes you can split this in multiple steps, or you can use monads to handle the composition for you, making you type less code, giving more information to the typesystem (mypy for python for example) about what is valid and what's not.
This is a completely different programming style, it's functional programming, aka: "how to make functions by composing other functions".
func read_file(filename string) (string, error) {
return "", errors.New("oops")
}
func foo() error {
a, err := read_file("a.txt")
if err != nil {
return errors.New(fmt.Sprintf("read a: %s", err))
}
b, err := read_file("b.txt")
if err != nil {
return errors.New(fmt.Sprintf("read b: %s", err))
}
// do stuff with a and b
return nil
}
func main() {
err := foo()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
In a language with exceptions: func read_file(filename string) string {
throw FileNotFound(filename)
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
try {
foo()
}
catch (err FileNotFound) {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.And before you say "you can add the filename to the error message in read_file()", what if the function is defined in a dependency you have no control over?
An exception is a typed data structure that contains way more informations and value to automate rescuing.
Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
func read_file(filename string) string {
panic(FileNotFound{filename})
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
defer func() {
if err, ok := recover().(FileNotFound); ok {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
foo()
}
However, exceptions are meant for exceptional circumstances (hence the name), not errors. A file error is not exceptional in the slightest. It is very much expected.While you can overload exceptions to pass errors (or any other value), that does not mean you should. Your use of exceptions for flow control (i.e. goto) is considered harmful.
> Your use of exceptions for flow control (i.e. goto) is considered harmful
Exceptions are a way to delegate error handling to the caller by giving them informations about the unexpected behavior. It implies that the expected behavior is the "happy path" (everything went well) and any deviations (errors) is unexpected.
This is far from a goto because you can have `try/finally` blocks without catch (or defer in golang).
Also, exceptions are just a kind of algebraic effects that do not resume. There was a proposal to JS for this: https://github.com/macabeus/js-proposal-algebraic-effects
This is also easier to test. assertRaises(ErrorType, func() { code... })
Almost every Go library I've seen just return an error (which is just a string), you'd need to parse it to assert that the correct error is returned in special conditions.
Errors are the "happy path", though. Your network connection was lost for the data you were trying to transmit so you saved it to your hard drive instead means that everything went well! Throwing your hands up in the air and crashing your program because you had to make a decision is not something you would normally want to do. If statements are present in most happy paths for good reason. That the inputs presented you with a choice does not remove you from the happy path.
Now, if you made a programming mistake and tried to access an array index that is out of bounds, then there isn't much else you can do but crash. Exceptions are appropriate for that kind of problem. They are exceptional in that they should never happen. Errors, on the other hand, are expected to happen and you can happily deal with them.
And that's not something you do thanks to try/catch. You just handle the error where it's meaningful to handle it.
The happy path of "make a request" is that there is no network error.
The happy path of "make sure this request is sent" is that you handle the unexpected network error to save the request to disk for further retry.
If the disk is full, you're not on the happy path anymore.
In Erlang/Elixir, there is a philosophy of "let it crash" which is basically "delegate the error handling/recovery to where it's meaningful to do so". For example:
start a supervised process to send a request
if there is an unexpected network failure, let it crash
the supervisor retries on its own
Or: start a process to send a request
if there is an unexpected network failure, let it crash
monitor the process to be notified when it crash
do something if it happens, like saving the request to disk
A "write_file" function can return many kind of errors: - file not found (some folder in the path does not exist)
- permission denied
- disk full
When you call write_file, you might want to handle some of those errors, and delegate the handling of others to your caller.You're still not addressing my main point. How do you check which errors you want to handle and which one you want to propagate with just a string describing your error ?
It is very weird that this subthread starts with "current state of the art".
And it is always most meaningful to handle it immediately, so what's the point of introducing a application-wide goto jump, amid Dijkstra's warnings that doing so is harmful when you're just going to catch right away anyway?
> The happy path of "make a request" is that there is no network error.
If there is a network error, you're still happy. It is not like you screwed up as a programmer. What is there to be unhappy about? The network error input to your function is very much expected and part of the "happy path" as much as any other input to your application.
> if there is an unexpected network failure, let it crash
Network failures are never unexpected. It would be exceptional to never experience a network failure.
> How do you check which errors you want to handle and which one you want to propagate with just a string describing your error?
Why would your errors be strings? When errors are just plain old values like any other it is true that you could resort to using strings to represent errors, but it would be quite unidiomatic to do so. Kind of like how you can overload exceptions to handle errors, but just because you can does not mean you should.
If you want to check for a specific error condition, then just define a value for that error and use `errors.Is` to check for it. This works as you'd expect with wrapping: https://go.dev/play/p/rJIlKKSYn9Q
> With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.
This is completely false! If you want to provide a structured error, then you just need to define a type for it. In your example, a Go programmer might use errors.Is(err, fs.ErrNotExist) and errors.As if they wanted to retrieve the specific file path that does not exist in a strongly-typed way, something like https://go.dev/play/p/hdHPLAVbQuW.
> Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
Certainly not! I think there is a misconception that "an error is a string" -- in Go, an error is actually any type that satisfies the error interface, i.e. has an `Error() string` method. It can be any type at all, and have as many other methods as you like in order to provide the functionality you need.
> what if the function is defined in a dependency you have no control over?
There's nothing stopping you from writing `throw new Exception(String.format("file not found: %s", filename))` in languages with exceptions either. In both cases, it would be recognized as poor API design.
Regarding stack traces, Go makes a strong distinction between errors (generally a deviation from the happy path) and panics (a true programming error, e.g. nil pointer dereference, where the program must exit). Errors do not provide stack traces since there is no need for them in a flow control context, panics do provide stack traces for useful debugging information.
may_fail_who_knows()
.map(use_value)
.map_err(some_error_processing)
.and_then(another_computation_which_can_fail)
.or_else(with_some_error_handling_that_can_rescue)
.unwrap_or(a_default_value)
Basically, instead of nested match expressions, you get a "pipeline". let result = may_fail_who_knows()?
.use_value_which_might_also_fail()?
.use_a_different_way_that_might_fail()?; match may_fail_who_knows() {
Ok(success) => {
do_something_with_success(success)?
},
Err(failed) => {
some_error_processing(failed)
}
}
As `do_something_with_success` in a closure can't early return from the function (since it's in a closure), which makes sense, but just annoying to read nested results. let x = match may_fail_who_knows() {
Ok(y) => Ok(another_computation_which_can_fail(use_value(x))),
Err(e) => with_some_error_handling_that_can_rescue(
some_error_processing(e)),
};
match x {
Ok(y) => y,
_ => a_default_value,
}
It's a bit more verbose than using the combinators, but someone coming across it for the first time will understand it immediately because there's less to remember to understand it (this is where go really shines).Also: by avoiding functors there are fewer subtle lifetime issues and `move ||` stuff to deal with and you can return from the containing function and use the `?` operator.
During the discussions of how `.await` was going to work for rust async there was the proposal to add other suffix keywords. So this would look like:
may_fail_who_knows()
.match {
Ok(y) => Ok(another_computation_which_can_fail(use_value(x))),
Err(e) => with_some_error_handling_that_can_rescue(
some_error_processing(e)),
}
.match {
Ok(y) => y,
_ => a_default_value,
}
Maybe not that different.You just had to write the concrete types (Ok and Err) out. What if these types are changed later on, e.g. to "Some(...)" and "None" or "Ok" and "ManyErrs(...)"?
As you said, it is easier to understand. Because it less abstract. This can be a good thing, but as well be a bad thing - but one thing is sure: while it does the same in the concrete case, the code is not "equivalent" when it comes to refactoring and certain changes.
Yes, there are a very specific and limited set of changes you could make to the types here and not have to change this code. You can't replace `Result<>` with `Option<>` because of `map_err`. You could replace `Result<>` with something else that is very `Result<>`y, but your flexibility would be very limited if you didn't want to change the signatures of `with_some_error_handling_that_can_rescue` or `some_error_processing`.
I'm sure it's possible to contrive an example where this would help, but I don't believe that it would be that much of a help very often in practice. I think it's just a bit more monady and people who take the time to learn monads then want to apply that wherever they can.
I'm not saying that the combinators should never be used, but that each additional one you use increases the cognitive burden of reading your code. So the question becomes: which of the combinators are worth it.
I would argue that `.map_err()` is useful as it compliments the `?` operator. Hopefully with (and often without) `try!` blocks many of the other ones can go away. In particular I think that language constructs are almost always better than `.and_then()`.