Reading the examples I found myself thinking, “that looks like a really useful pattern, I should bookmark this so I can adopt it whenever I write code like that.”
The fact that I’m considering bookmarking a blog post about complex boilerplate that I would want to use 100% of the times when it’s applicable is a huge red flag and is exactly why people complain about Go.
It feels like you’re constantly fighting the language: having to add error handling boilerplate everywhere and having to pass contexts everywhere (more boilerplate). This is the intersection of those two annoyances so it feels especially annoying (particularly given the nuances/footguns the author describes).
They say the point is that Go forces you to handle errors but 99% of the time that means just returning the error after possibly wrapping it. After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.
I hope someday they make another attempt at a Go 2.0.
Just pass along two hidden variables for both in parameters and returns, and would anything really change that the compiler wouldn't be able to follow?
i.e. most functions return errors, so there should always be an implicit error return possible even if I don't use it. Let the compiler figure out if it needs to generate code for it.
And same story for contexts: why shouldn't a Go program be a giant context tree? If a branch genuinely doesn't ever use it, the compiler should be able to just knock the code out.
The main problems seem to me to be boilerplate and error types being so simplistic (interface just has a method returning a string). Boilerplate definitely seems solvable and a proper error interface too. I tend to use my own error type where I want more info (as in networking errors) but wish Go had an interface with at least error codes that everyone used and was used in the stdlib.
My rule of thumb on annotation is default to no, and add it at the top level. You’ll soon realise if you need more.
How would you fix it if given the chance?
It should be the same handling as all other types. If it feels clunkier than any other type, you've not found a good design yet. Keep trying new ideas.
The main reason is more to do with maintaining Go code than writing it: I find it very helpful when reading Go code and debugging it, to see actual containers of values get passed around.
Also, whenever I write a bit of boilerplate to return an error up, that is a reminder to consider the failure paths of that call.
Finally, I like the style of having a very clear control flow. I prefer to see the state getting passed in and returned back, rather than "hidden away".
I know that there are other approaches to having clear error values, like an encapsulated return value, and I like that approach as well - but there is also virtue in having simple values. And yes there are definitely footguns due to historical design choices, but the Go language server is pretty good at flagging those, and it is the stubborn commitment to maintaining the major API V1 that makes the language server actually reliable to use (my experience working with Elixir's language server has been quite different, for example).
I disagree. I feel like I constantly understand precisely what the language is and is not going to do. This is more valuable to me than languages with 100 sigils that all invoke some kind of "magic path" through my code.
> forces you to handle errors but 99% of the time that means just returning the error after possibly wrapping it
How do you universally handle an inventory error? The _path_ to and from the error is more important than the error or it's handling clauses.
> After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.
Isn't the point of the above that no matter which you choose the code is mostly the same? How much of an impact is this to refactor when you change your mind? For me it's almost zero. That right there is why I use go.
Go's context ergonomics is kinda terrible and currently there's no way around it.
It’s ironic how context cancellation has the opposite problem as error handling.
With errors they force you to handle every error explicitly which results in people adding unnecessary contextual information: it can be tempting to keep adding layer upon layer of wrapping resulting in an unwieldy error string that’s practically a hand-rolled stacktrace.
With context cancellation OTOH you have to go out of your way to add contextual info at all, and even then it’s not as simple as just using the new machinery because as your piece demonstrates it doesn’t all work well together so you have to go even further out of your way and roll your own timeout-based cancellation. Absurd.
When writing your tests:
1. Ensure all error cases are identifiable to the caller — i.e. using errors.Is/errors.AsType
2. Ensure that you are not leaking the errors from another package — you might change the underlying package later, so you don't want someone to come to depend on it
As long as those are satisfied, it doesn't matter how it is implemented.
The rule of thumb is to wrap always.
error:something happened:error:something happened
I need to start getting used to context with cancel cause - muscle memory hasn't changed yet.
I quite enjoy C# and F# and while they are low boiler plate, you can really learn them in a week or two the way you can learn Go.
And even you don't know anything about Go, you can literally jump into the code base and understand and follow the flow with ease - which quite amazes me.
So unfortunately, every language has trade offs and Go is not an exception.
I can't say I enjoy Go as a language but I find it very, very useful.
And since many people are using LLMs for coding these days, the boiler plate is not as much an issue since it be automated away. And I rather read code generated in Go than some C++ cryptic code.
The community is special and now with the original authors mostly gone, and AI into the mix, I don't see it ever happen.
We will get ridiculous Go 1.xyzabc version numbers.
Java, C# and so on are scripting languages that compile to bytecode that's then run by a painfully slow interpreter.
Is there any equivalent in major popular languages like Python, Java, or JS of this?
Example:
maybeVal <— timeout 1000000 myFunction
Some people think that async exceptions are a pain because you nerd to be prepared that your code can be interrupted any time, but I think it's absolutely worth it because in all the other languages I encounter progress bars that keep running when I click the cancel button, or CLI programs that don't react to CTRL+C.In Haskell, cancellability is the default and carries no syntax overhead.
This is one of the reasons why I think Haskell is currently the best language for writing IO programs.
(I also think there's some wonkiness with and barriers to understanding Python's implementation that I don't think plagues Go to quite the same extent.)
Also, a sibling poster mentioned ZIO/Scala which does the Structured Concurrency thing out of the box.
https://github.com/ggoodman/context provides nice helpers that brings the DX a bit closer to Go.
There's a stop_token in some Microsoft C++ library but it's not nearly as convenient to interrupt a blocking operation with it.
The contexts and errors communicate information in different directions. Errors let upstream function know what happened within the call, context lets downstream functions know what happened elsewhere in the system. As a consequence there isn't much point to cancel the context and return the error right away if there isn't anybody else listening to it.
Also, context can be chained by definition. If you need to be able to cancel the context with a cause or cancel it with a timeout, you can just make two context and use them.
Example that shows the approach as well as the specific issue raised by the post: https://go.dev/play/p/rpmqWJFQE05
Thanks for the post though! Made me think about contexts usage more
cancel(fmt.Errorf(
"order %s: payment failed: %w", orderID, err,
))
return fmt.Errorf("order %s: payment failed: %w, orderID, err)
Not only that, isn't this a "lie"? You're cancelling the context explicitly, but that's not necessary is it? Because at the moment the above call fails, the called-into functions might not have cancelled the context. There might be cleanup running later on which will then refuse to run on this eagerly cancelled context. There is no need to cancel this eagerly.Perhaps I'm not seeing the problem being solved, but bog-standard `return err` with "lazy" context cancellation (in a top-level `defer cancel()`), or eager (in a leaf I/O goroutine) seems to carry similar functionality. Stacking both with ~identical information seems redundant.
> lemoncucumber
> it can be tempting to keep adding layer upon layer of wrapping resulting in an unwieldy error string that’s practically a hand-rolled stacktrace
I thought this was the whole reason to wrap errors, to know where they passed up the chain.
Funny how it seems no matter the subject, if Go is involved, errors get discussed.
The code that justifies the special context handling:
if err := chargePayment(ctx, orderID); err != nil {
cancel(fmt.Errorf(
"order %s: payment failed: %w", orderID, err,
))
return err
}
Why not simply wrap that error with the same information?