Go’ing Insane: Endless Error Handling
jesseduffield.com
jesseduffield.com
fn my_func() -> Result<(), Error> {
foo()?;
bar()?;
baz()?;
Ok(())
}
// ---
let val = bar()?;
println!("{}", val);
// ---
fn my_func() -> Result<i32, Error> {
foo()?;
bar()?;
let val = baz()?;
// ... more stuff
Ok(val)
}
// ---
fn my_func() -> Result<(i32, String), Error> {
foo()?;
bar()?;
let (val1, val2) = baz()?;
Ok((val1, val2))
// Those last two lines could even be just `baz()` or `Ok(baz()?)`.
}
Rather close to the world the author wants to live in.``` let (val1, val2) = baz() .map_err(|err| add_my_context(err, "some context"))?; ```
Also, the `?` operator automatically calls `into()` on the error that it was given, which will convert it into the desired error type (assuming there's a conversion implemented for it), so you only need the `map_err()` approach when you need to add context.
I'm not sure what the idiomatic Rust way of logging before returning the error is - in the code I've written (and read), you'd add return the error with added context and leave the handler to decide whether to log.
In my experience, it is pretty ergonomic.
let foo = foo()?;
// Expanded
let foo = match foo() {
Ok(v) => v,
Err(e) => return Err(From::from(e))
};
If you're using your own error type then you can impl From/Into yourself to capture additional context from the source error (or a stacktrace) in addition to using map_err() as others have suggested foo().map_err(|err| WrapperError::new(err))?
For context, typically you would use a library like `anyhow` which allows you to do: foo().context("Context string here")?
or foo().with_context(|| format!("dyanmically generated context {}", bar))?
I don't know of any pre-built solutions for logging, but you could easily add a method to Result yourself that logged like: foo().log("log message")?
or something like: foo().log(logger, 'info', "log message")?
The code to do this would be something like: use log::error;
trait LogError {
fn log(self, message: impl Display) -> Self;
}
impl<T, E> LogError for Result<T, E> {
fn log(self, message: impl Display) -> Self {
error!("{}", message);
self
}
}
Then you'd just need to import the `LogError` error trait at the top of your file to make the `.log()` method available on errors.Is chaining+traits+wrappers+map_err truly less complex than a conditional:
if BAD {
Log()
Metric1()
Metric2()
}Having said that, if you're outputting the same metrics for every error then a method that does this for you makes a lot more sense than repeating yourself all over the place.
Remember also that .map_err() need not be significantly different from that if block; it can be essentially just a slightly different spelling:
let data = some_call().map_err(|e| {
log();
metric1();
metric2();
e
})?;
But I would draw your attention in all this to the fact that you’re dealing with algebraic data types, enums with data attached to the variants; this isn’t just some nullable data value and nullable error where you can say `if error { … }`; in order to get at the data, you need to handle the error. This has profound implications that, once you’re used to it, will shape your coding style and techniques even when you return to other languages.On things like metrics, I’d also mention that sometimes you may be better served by an RAII pattern, which can be made to be the rough equivalent of a defer statement in Go. Especially if you’re pairing start/end metrics, RAII can be particularly good at that.
With something like a functional effect system you can even do better than that. For instance with Scala ZIO you can do something like:
``` val result = for { fooResult <- foo().tapError(e => console.putStrLn(s"Error in foo!: $e") barResult <- bar(fooResult).tapError(e => console.putStrLn(s"Error in bar!: $e") bazResult <- baz(barResult) } yield bazResult ```
Then when you run the bazResult effect you get either an error or the result type which you can handle however you like.
match foo() {
Ok(val) => val,
Err(err) => return Err(err.into()),
};
(This isn’t exactly how it’s implemented—that detail is unstable and has actually changed <https://github.com/rust-lang/rfcs/pull/3058> since ? was stabilised, without disrupting things!—but it’s equivalent in this instance. As examples of how it’s not exact: you can use ? on Option too; and inside a try block (unstable), ? isn’t a return, acting more like a break-with-value.)For wrapping the error: note the .into() in the desugaring: it’ll perform any necessary type conversions which is the normal way you’d do error wrapping. But if you want to do anything more than that, or adding specific context, you’re still in luck! You can use method chaining to manipulate the result. In the standard library is map_err <https://doc.rust-lang.org/std/result/enum.Result.html#method...>:
foo().map_err(|err| f(err))?
And you can add other methods to such types with extension traits; as an example, the anyhow crate, popular for application-level error handling (where you don’t care about precise modelling of errors) makes it so that, on importing anyhow::Context <https://docs.rs/anyhow/1.0.44/anyhow/trait.Context.html>, you can add context to an error like this: foo().context("foo failed")?
foo().with_context(|| "foo failed")?It’s up to the caller what to do with an error - panic, return it in turn, wrap it, or whatever else makes sense in context. It’s semantically quite similar to Go - except with better library support (Result instead of returning a tuple) and better syntax (the “?” operator instead of manual checks).
The question mark operator just gives you an easy way of doing perhaps the most common handling of errors, which is propagating it up to the caller to worry about. So somewhere along the way you’re going to need to actually handle it (or explicitly ignore it), though it’s also possible in simple programs to let Rust handle it, by having your main function return a Result (that is, something like `fn main() -> Result<(), Error>`), in which case if it’s an Err it prints the error (roughly `eprintln!("Error: {:?}", err);`) and exits with status code 1.
For truly exceptional situations, Exceptions are a good match because actually you wanted a control flow change. If the NetworkFailed maybe all of this complicated multi-node-synchronisation code is useless and we should handle that in code for NetworkFailedExceptions.
But one program's exceptional situation is another's business as usual, our desktop network visualisation icon does not want to eat a NetworkFailedException, the fact that NetworkFailed is just a reason to change the little icon to red instead of green, not exceptional at all.
This is particularly egregious when doing large data processing. If I doBackups() sending forty different sources to six backup servers, I actually don't want the failure of one backup server to handle one of the forty sources to blow up the entire function, I would much rather get back a detailed overview and go OK, 5 out of 6 ain't bad, no need to set off alarm bells and wake up the sysadmins. Error handling Go's way makes this a bit ugly but practical, Rust's way makes it feel reasonably ergonomic, in languages like C++ or Java it was kinda horrible (but C++ is slated to get an Expected class to mostly fix this in C++ 23).
Having a Result type with statically enforced checking sounds good though.
err := foo()
if err != nil {
return err
}No. `?` performs an optional conversion and an early return, that’s it (kinda).
> If not, how do you know where the error has come from?
By default you don’t. There are utility libraries you can use to attach such information to error types (and eventually the stdlib will provide one), but OOTB an error value is… quite literally anything (it should probably implement `Error` but doesn’t actually have to).
This is in keeping with the idea that hidden costs should be avoided when possible, and that reified results should be the norm so should have as little requirement as possible.
It depends. `?` will convert the inner error type (the value from the function to its left) to the outer error type (the return type of the function containing this line). That conversion function can be defined by the inner error type. If the conversion generates a stack trace (or adds to an existing one), then there will be one.
Therefore, "Is there always a stack trace available?" is a tricky question-- it was seen as unimportant during the 1.0 days because they wanted to be able to accommodate low-resource environments.
Today, there's third party error handling libraries that cut out error creation / manipulation boilerplate, that might create stack traces for you, and there's efforts ongoing to see how it might be added to the stdlib.
For example, this code:
void foo() {
var res = acquireResource();
useResource(res);
releaseResource(res);
}
is wrong even if no errors can be thrown. By contrast, this code is correct, regardless of whether any of these functions can throw: void foo() {
try {
var res = acquireResource();
useResource(res);
} finally {
releaseResource(res);
}
}
Similarly, in Go this function is wrong: func foo() error {
res, err := acquireResource()
if err != nil {
return fmt.Errorf("error acquiring resources while foo-ing: %w", err)
}
err = useResource(res)
if err != nil {
err = fmt.Errorf("error using resource while foo-ing: %w", err)
}
releaseErr := releaseResource(res)
if releaseErr != nil {
err = fmt.Errorf("%w; failed to releasing resource while foo-ing: %w", err, errW)
}
return err
}
even though I don't have any exceptions.The correct way to do this in Go would be:
func foo() error {
res, err := acquireResource()
if err != nil {
return fmt.Errorf("error acquiring resources while foo-ing: %w", err)
}
defer func() {
err := releaseResource(res)
if releaseErr != nil {
logInSomeWay(fmt.Errorf("failed to releasing resource while foo-ing: %w", err))
}
}()
err = useResource(res)
if err != nil {
return fmt.Errorf("error using resource while foo-ing: %w", err)
}
}
So overall, whether a function finishes early SHOULD be irrelevant.If you want to see what throws "FooException", you need an IDE to do it, unless you have the whole Javadoc memorised. It might be a hundred lines up and in the middle of a foo().bar().baz() chained series of method calls.
And yes, you need to know. The same exception in two different places might mean two very different things in terms of what actually broke, or what you should do to recover or fail the task at hand. Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context. The same work you'd do in Go.
This isn't any different in Go. Tracing Exceptions is actually often easier since they have unique names that you can grep for, whereas err is always err, so your only recourse is to trace across the callstack.
It's, like, actually worse.
> Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context.
What? If you have a decent root level error message, that + a stack trace is almost always enough, and is as good or better than what you get in go, since the errors are more structured. I'd generally consider the donkey work you're describing an antipattern. Adding extra context or re-raising an exception should be done only rarely, when you have confidence that the new context you're providing is more useful than the prior (and lots of good languages support exception chaining so you get all of the context from many errors, instead of just one string).
That's a completely different problem, and one that is not unique to errors or exceptions. For any polymorphic type where you want to handle different variants differently, you need to know the possible variants, and this information is, by definition, not present in the code sending the polymorphic value. This is true whether you want to handle exceptions in C++, Optional t in Haskell, or interface values in Go.
> Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context.
Wrapping up exceptions at every function is a huge code smell. You sometimes want some level of wrapping, especially around module boundaries or in functions with highly relevant context along the way, but generally most functions along a call stack don't have anything to add to a lower level exception - especially if the lower-level library is well made. For example, fs.PathError in Go and System.IO.FileNotFoundException in C# include a way to programatically find the name of the file that was not found, so even if thrown from a low level, a higher level still has the most relevant context available without wrapping. In contrast, java.io.FileNotFoundException does not include this information unless you parse the error string, so you would have a reason to wrap it with more relevant context.
1. https://www.infoq.com/news/2019/07/go-try-proposal-rejected/
Resources that need to be closed at function exit must be wrapped with `using` or `try/finally` in Java; with `defer` in Go.
Functions that can handle specific errors from a sub-function need to know about that error type in Java, error type or error value in Go.
Functions that can't handle any specific errors from their callees don't need to do anything in Java; they need to propagate any error in Go.
It's true that in Java it's less clear where a function can end, since any statement can potentially throw. This is technically true in Go as well with panic(), but let's accept that that is much more rarely used. But either way, if the function has any cleanup to do, that cleanup must be done in try/finally or defer, otherwise the function is brittle. So, why do I care if the function can throw an exception in the middle?
When I'm writing Go, it returns where it says "return".
Beyond that, you don't need to care if the function will terminate early because of an exception - as long as all necessary cleanup is handled regardless of exit reason, you don't care that the function will finish early if an HTTP request fails before using the value of the request.
And usually you only want to catch and re-throw exceptions around module (in the broad sense) boundaries. If you catch and re-throw exceptions in every function, you're either losing lower-level context completely OR you're leaking implementation details anyway if you're chaining up the old error.
In Go, the error needs to be manually propagated through every function, with enough context to understand what's going on. In Java, the error needs to be manually handled at every module boundary; function-level context is given automatically by the call-stack.
The fact is that even code that does no resource acquiring could throw an exception. It might not even be a documented exception. And when it throws that exception that you may or may not have been expecting some code somewhere is going to have to figure out what to do. In java the only real way to communicate an error is to throw an exception. For some reason culturally Java has decided that it's too much work to distinguish between errors that should crash the app and errors that should be handled by the caller in some way. As a result when an exception outside of your code happens in production and you read the exception in your logs it will:
1. Not have a significant part of the information you need to debug the problem.
2. Be in code you may not have easy access to go read.
3. You will probably have no idea why it's happening.
Eventually for a long enough lived codebase you will have encountered and documented all of these in a runbook somewhere or you won't and everyone will have to have learned them over and over again.
If you are on an exceptional team you'll have caught and then wrapped or handled these exceptions to make operating the software in production less painful. But all of it could have been avoided if java had chosen a better path.
Make it impossible trap runtime exceptions. They just crash the program. Make trappable exceptions checked exceptions. The code would get more verbose. But the operational pain would have been vastly reduced.
Errors are a part of your API. Unchecked Runtime exceptions pretend like 50% of your API surface doesn't exist.
If these errors are things that the system administrator can handle, they will find them in the logs, the cause of the error will hopefully be clear enough if enough effort was spent on making it that way, and the sysadmin will fix the cause. If the error is something that the programmers never anticipated, nothing more can be done and a bug needs to be open with the developer. In some cases, even though the problem was with the environment, error reporting may have been fudged and the sysadmin may not be able to understand what they are suppose to fix - bad error reporting.
I fail to see how exceptions hurt with any of these cases. I have no more or less information about error cases seeing `func foo() error` or `void foo() throws Exception`, so neither helps me know what errors I should handle. It's no harder in Java to add context to errors when you really want/can - in fact, it's easier:
try{
stuff1();
stuff2();
} catch (Exception e) {
throw SpecificError("I was doing this when something else happened", e);
}
vs err = stuff1();
if err != nil {
return fmt.Errorf("I was doing this with stuff1 when something else happened: %w", err)
}
err = stuff2();
if err != nil {
return fmt.Errorf("I was doing this with stuff2 when something else happened: %w", err)
//note: different error message, since we're missing call stacks to know where this actually failed
}
Errors are part of your API - agreed. In C# or Java without checked exceptions, you have to assume that any function can throw any exception. In Go, you get exactly 1 more bit of information: a function tells you IF it returns an error or not - same as modern C++ with `noexcept`. But if you actually want to know what errors are returned, you're SOL in most languages (Java with checked exceptions helps, but causes other problems). And none of these languages helps you with adding context to errors, except the so so context of stack traces in C# and Java.I find that extra bit of information to be crucial you do not. I think you are perhaps discounting the value that Go allows you to add in your apis by making the error type explicit which then does tell you what error you might be getting. Go forces the programmer to make the fact that there is an error explicit which is good. If it also forced the developer to be explicit in which type of error it can return that would also be good since it would enforce API boundaries for errors. But I think Go is still ahead of the game compared to Java on this one.
The whole attitude with Kotlin is that exceptions are exceptional and that you probably won't be doing anything productive with them beyond logging. Using exceptions for conditional logic is a bit of a design smell. Sometimes you have to with some older Java libraries. But it's not what you are supposed to do. Otherwise, catch exceptions centrally. When using co-routines, uncaught exceptions actually cancel the co-routine scope (and the the other co-routines in them, if any), which gives you an opportunity to deal with that in a sane way. You can have error handlers attached to those to deal with that. Structured concurrency is a buzzword they like to use for this notion.
I've been on a few Go projects; never as a main developer but enough to understand what is happening. I appreciate its simplicity; it's easy to get into. That is IMHO the main value of go: getting stuff done. But the verbosity is a bit painful if you come from something higher level. With Kotlin's native compiler slowly getting usable, I could see myself opting for that where I might have used Go or something else in the past (e.g. command line tools).
Some HN comments toward Go are a bit baffling.
Does that seem reasonably representative? To a non-Go programmer, it doesn't seem that bad, but it sure does have a lot of error-handling boilerplate -- squinting at it, something around 50% of the non-comment lines? It just seems kind of weird, if you have to write "err != nil" and "return nil, err" over and over, not to have any syntactic sugar for it!
There's what looks like missing return values on lines 64 and 69, then down on line 93 there's an explicit "return // ignore I/O error".
Is this file outdated, or otherwise not a good example? I used Google to find it, and it says "Copyright 2020" in the header.
The missing return values on 64 and 69 are the "Named Return Values" thing that OP mentions - the returns are the variables named in the function declaration.
No offense, but I think it's Stockholm Syndrome. It reminds me of what the JavaScript folks used to say 5 years ago: "No, it's not a terrible language. I love JavaScript's callback hell." Of course once it gained promises/await, now they _really_ love it.
I've been using Go for the last year and while I do genuinely enjoy the simplicity of the language, Go does have these annoying little customs like pebbles in your shoe.
You can _say_ it forces you to think about error handling, but I find it does not. To be honest, I'm able to not think about error handling just fine. It just forces me to write boilerplate when I'm trying to crank out a quick non-robust prototype.
So if you think Data first, Go is great. If you think in terms of method chaining and verbs or nouns first, then Go won't treat you kindly.
Whereas when I use Rust's Result types, I tend to not add any additional context with `map_err` (until I need to investigate the cause of an error and have to retrofit it).
There's still definite room for improvement though (I think the Go 2 proposal will help a lot).
Sum types and pattern matching to enforce that would be even bettern. What I don't like about Go error handling isn't that it's "verbose" but that it's primitive.
People who use Go generally don't care about the tedious error handling or lack of generics or whatnot; people who do care about those things generally don't use Go.
I'd love to hear from somebody who reluctantly tried Go despite initially caring about this stuff, and was won over.
I still care about stuff like this, and there are definitely ergonomic improvements that could be made.
I guess my feeling is that overall, for the programs I write (network services, broadly speaking), the benefits greatly outweigh the annoyances.
on the positive site, in my experience, Go:
- Is easy to learn
- Has a relatively small surface area
- Generates static binaries
- Forces you to consider error handling
- (I'm listing this as a benefit as well as it's a downside!)
- Is pretty darn fast, both at build time and run time
- Is mostly well documented (text/template documentation excluded)
- Takes backwards compatibility seriously
- Cross compiles easily
- Has generally very good and fast tooling
- Has superb network-related libraries
- Has excellent support for concurrency
on the negative side, it: - Is often not pretty or elegant
- It does have an aesthetic of its own once you're used to it, but it's never as satisfying as nailing something exactly right in haskell or rust, or doing something highly dynamic and fancy in python or ruby
- Sometimes resists nice algorithms and data structures
- this is why I would never want to use it for data science, for example
- Is very opinionated
- if you want something it doesn't agree with, you just don't get that thing
For much of the work I do, the positives outweigh the negatives. Maybe that helps?If you use the result. Its easy to overlook that some more procedural functions might return only an optional err value.
See also stuff like append sometimes mutates and returns a reference to a slice with the same backing array, and sometimes returns a slice with a different backing array. Nothing going to remind you you forgot to copy before returning.
Yes, there are quirks in the Go language but you have to make design decisions.
Yes. People who write in Go value these things. I don't value what Ruby values. And that's okay. My Go programs will require fewer tests and run faster and still compile years later.
a,err:=slice[i] ...
For instance, if you have a function that does multiple things in sequence, how common is it to reorder the individual steps? Most times you have an intrinsic ordering between each step. And even if you reorder at some point, flipping some ":=" into "=" or vice versa based on compiler errors is not as big of a deal as the author makes it out to be.
I do see the point for some syntactic sugar for error handling though, long if chains are terrible for clarity as well. And arguably ? and ! are typographic symbols our brains are well trained to spot.
There is nothing to learn - it's an ugly wart you have to accept if you're using the language. There's no going around the fact that it's verbose, error-prone, and makes code hard to read and review.
Strong disagree on that. The fact that it’s explicit makes it extremely easy to reason about and Go code reviews are a breathe. It’s verbose, I’ll give you that, but the rest doesn’t hold.
> For me and my past teams
> piles of boilerplate verbiage like this
I'd love to have a look at your code because this isn't my experience at all. What's your background ? What's your team background ? Are you writing go code with the mindset of a java/cpp/whatever dev ?
Have a look at the source code of the top 10 go github repos, you won't find any of what the blog post talks about, because it simply isn't the way it's done
A quick look around k8s certainly shows evidence of lots of boilerplate-y error handling.
To give my perspective, I come from a primarily python background, but have used C++ and JS enough to know my way around, and Java was what I primarily learned programming in.
I write relatively little go, but I review a fair amount of it. The people whose code I'm reviewing are competent, idiomatic go programmers who follow go's style guide (https://github.com/golang/go/wiki/CodeReviewComments) to the extent that it exists.
I find it far more difficult to tease out what a stanza of go is doing compared to an equivalent stanza of python due to the visual noise. In python (and other languages), I usually use a somewhat functional/declarative style, so I use abstractions like comprehensions and such to allow terse transformations of data.
Go doesn't have these. In essence, the tradeoff go makes is to not provide abstractions in the language. This forces users to recreate them[1] ad-hoc, or suffer without abstractions at all (which doesn't scale). This makes everyone suffer, instead of just people unfamiliar with the abstractions suffer.
[1]: https://medium.com/@robertgrosse/parallelizing-enjarify-in-g..., and yeah yeah yeah go finally is getting generics, but the same general comment applies to other common and useful abstractions. Error handling, functional stuff, take your pick.
If the only thing you care about is the happy path in your review then Go code will annoy you. But you are also only doing half of your job in that review.
Also, it's much easier to look for catch{} blocks than it is to look for Go code that actually handles errors, since code that forwards errors, code that wraps errors, and code that handles errors all looks more or less the same in Go (if err != nil {...}).
And until you understand what the resources are needed for, you can't actually understand if the error handling is correct.
Not to mention, to get around this, the first skill everyone reading Go code internalizes is ignoring any block that starts with `if err != nil {` that isn't more than 1-2 lines long.
In contrast, when reading Java/Python/C#/CL, exception handlers are very rare, so you actually pay attention when you see one.
I would still like to find some way to make go’s error handling a little easier on the eyes, but I’ll take this over having to be constantly vigilant about handling every possible exception.
https://play.golang.org/p/oHFobaOexwR
Of course, I know now that I was 'holding it wrong'.
func foo() error {
var err error
err = bar()
if err != nil {
return err
}
err = baz()
if err != nil {
return err
}
return nil
}
And the final "?" proposal doesn't actually deal with the errors. Part of the point of Go's error handling is to force us to deal with the errors. Endless "if err != nil {return err}" statements is kinda an antipattern in Go. Yes it's common, but I'm sure that's because we've been trained by exception handling to just pass the errors up.A better (but still not perfect) pattern is to wrap the errors:
if err != nil {
return fmt.Errorf("tried to foo the bar with parameter %s, failed with: %w", baz, err)
}
And obviously, an even better pattern is to actually deal with the error - retry after a delay? return a custom "abandon goroutine" error? Endlessly passing the errors up the stack until some function passes an incomprehensible error message to the user is what we're trying to get away from here.But I totally agree that blindly retrying is a bad practice.
Languages that implement error handling well do not communicate error states in the form of massive strings like `call to f() failed: g() returned an error with parameter '123': error in h(), with argument x = 'interface{}{}': connection failed`.
In other words, exceptions with a stack trace. It's funny how that pattern is being reimplemented because it's not available in go
The error interface's only member is `func Error() string`. If you want to do fancy things with errors rather than accumulate a massive string then there is absolutely nothing stopping you. As long as you include a `func Error() string` in there too then whatever you implement will be perfectly compatible with the std library and any other Go code that uses errors.
Try different strings? Maybe run through some AI code to figure out what the string really should have been? Most errors you just have to throw your hands up and declare something's wrong. I write in Go and Kotlin and Kotlin has been much less buggy because errors naturally flow up where they are logged and stop the normal execution. I've seen a number of examples in Go where the error was not returned, causing silent failures.
edit: The thing with NewFromString is that the string you're trying to convert to a decimal can't be. So yes, you're going to have to try a different string if you want this to work. Or you could deal with it a different way - maybe you could see if it converts to a float and then convert the float to a decimal?
And in the typical exception-using code base I've seen, there's one try block at the bottom of the stack with a catch block that throws whatever error message it has at the user. Anything is better than this.
// NewV4 returns a randomly generated UUID. func NewV4() (UUID, error) { return DefaultGenerator.NewV4() }
You want a UUID but it might give you an error. What do you do except also return an error?
I find this slightly at odds with the argument (made by other people, not you) that in Go you’re not reliant on the IDE, because all the program behaviour is immediately visible.
I don’t really understand the objection to using an IDE to look up overridden operators and so on. It seems like it’s in the same category as having a type-checking compiler that knows how to look up a type definition and check that you’re using valid operations. There’s no way all the information you need is going to be nearby in the text in any reasonably-sized program.
So manually reinvent exceptions :-)
func test() (result interface{}, err error) {
_, err = someOtherFunction()
}
The thing is, you probably want to handle the error eventually, and that is just as verbose in Rust [1] as it is in Golang (IMHO). One can of course debate if it's more pleasing to use a "match" operator than an if/then/else loop to handle errors, but to me the difference is marginal. BTW one could even mimick Rust's type-based errors in Golang by returning a single interface{} type that's either a result or an error and then using a type switch, but that seems just silly and I have never seen that in the wild.1: https://doc.rust-lang.org/rust-by-example/error/multiple_err...
In go, you constantly have to `if err != nil { return }` even with named return values. This is the part that breaks up the flow constantly and hurts readability.
In Rust, the ? macro also handles this.
Rust's '?' sugar effectively replaces the whole `if err != nil { ... }`, meaning it wasn't really honest to list Rust as another language that proves that error handling just has to be verbose and repetitive.
I used to like passing errors up the stack like you describe, but this invariably leads to hard-to-debug programs that terminate with unspecific errors like "i/o error", leaving it to the user or developer to figure out in which exact branch of the 9-layer call stack that error originated. If your error handling is exception-based and if your interpreter/runtime generates tracebacks you can do that, but not if you explicitly handle errors within the regular control flow.
You might want to add a caveat to 'invariably', something like: 'provided you're using a language where errors are modelled as one big mashed-together string' ;)
If we assume the basics of how Go is designed are an invariant, I think the best change would be adding syntactic sugar to return a struct satisfying the error interface, annotated with the (statically encoded) symbols and line numbers, and then also the arguments for those function calls. I have never seen a Go codebase whose hand-written 'contextual' error handling blocks provide even this, let alone more useful information. If you have, I'd be interested to hear about it!
failed to start a session: failed to read the session config file: i/o error
This is perfectly equivalent to java.lang.IOError
in startSession() line 192
in readConfig() line 123
in openFile() line 131
Except that with Exceptions you get this for free, with more context than naive wrapping (and much more than `if err != nil { return err }` ).Usually with exceptions, you want to catch and add more context at every layer boundary, and get the call stack for free.
Also, raising and catching exceptions is expensive in many languages. I know that in Java it's fast, but other languages like Python produce significant overhead when creating and raising an exception, that's something one should consider as well.
Also, the performance argument counts against your point, not for it. Exceptions rarely happen (when used properly), and when they are not raised, they cost nothing. Whereas go-style errors require explicit checks every single time you execute an operation which can fail.
But I think that pattern matching, and even checked exceptions, are much less verbose than constantly checking the return value. That doesn't mean that they're better, just less verbose. (Exceptions in particular - I can invisibly leave this function at points that are not specified. Does that leave everything in a clean state in all possible circumstances? Explicit error returns are much more verbose, but may be easier to reason about, because the information you need is all explicitly visible.)
I don’t see how you can say that monadic results aren’t strictly better: they’re safer, less verbose, and give the option of abstracting around. Odds are they’re even more efficient.
Take the error returned by some function call, add a bit more context so I can differentiate this XYZ error from the others, then pass it along to the caller.
I really couldn't care less about the details of how that happens and I find it puzzling that someone would write such a lengthy blog post about what is to me a completely trivial matter.
try {
foo();
val = bar();
baz();
} catch (Exception $e) {
// all lumped together, not good
}
try {
foo();
val = bar();
baz();
} catch (RangeException $e) {
// who is throwing what?
} catch (NotFoundException $e) {
// who is throwing what?
} catch (SomeOtherException $e) {
// who is throwing what?
}
try {
foo();
} catch (SomeOtherException $e) {
// very verbose
}
try {
val = bar();
} catch (NotFoundException $e) {
// very verbose
}
try {
baz();
} catch (RangeException $e) {
// very verbose
}When just propagating, exceptions have no verbosity:
foo();
bar();
baz();Note that you don't need custom error types, just exported Error instances. These were in use even before wrapping was introduced with Go 1.13 (2019); one had to compare or type-assert directly instead of unwrapping.
I do think that what seems obviously true and categorical in the absence of concurrency is quite a bit more subtle when concurrent. It’s obvious when rewriting the kind of chains where ‘?’ makes perfect sense from sequential to concurrent invocation.
By no means do I think Go is perfect or can’t learn from Rust but I do think it’s important to consider the tradeoffs against Go’s colorless concurrency - it’s a very compelling affordance. Like garbage collection it’s easy to show the pros and cons in a codebase the size of a demonstration but at scale it’s a decision with pros and cons.
Go plays at being a system programming language, but it's really not. It's peers and competitors are languages like java and c#, not rust or c++. When you look at it from that lens, it fares badly. There's just a clunky feel that permeates the entire language, and it's creators seem to have no interest in addressing it.
I've said it before, and I'll say it again. Golang is popular because it was written by Thompson, and embraced by google. You have a whole load of devs that will simply embrace anything with such a pedigree because it 'must be state of the art'
That seems rather dismissive of people. The devs I know are not sheep.
Now if you had said that we have a whole load of managers that will simply embrace anything because it 'must be state of the art'...
Dude, at the end of the day I want something done. And golang has good libraries (esp network stuff), good IDE support, and compiles to single binary that's reasonably fast and easy to deploy.
Show me another language that covers all these.
If the blog post was titled "Go is unusable in production" then sure, he'd be wrong. But he's simply criticizing the language design. (Mostly, I might add, in the hopes that it will someday improve; the author writes Go professionally, he's not just some random hater.)
From the article:
if val, err := bar(); err != nil { // ERROR: val declared but not used return err } fmt.Println(val) // ERROR: undeclared name: val
If you want to use the val after the if, you could use it in an else:
if val, err := bar(); err != nil { // ERROR: val declared but not used return err } else { fmt.Println(val) // this is ok }
Go could easily do that too if it weren't as afraid of "complex" features.
In exception-based languages, if you don't handle an error, it will be bubbled up and possibly kill the whole program. Similarly, in Rust if you handle an error "lazily" by `unwrap`-ping it, it will possibly terminate the entire program. In these languages, if an error happens in line X and it's handled "lazily" or even not handled at all, line X + 1 won't be executed. Not in Go.
Ignoring errors might be okay if the zero value returned when there's an error is expected by the caller. For example:
// If the caller expects the default value of `count` is 0, this is fine
count, _ := countSomething(...) // return (int, error)
However, in many cases the zero values are garbage values because the caller is expected not to use it if there's an error. So, if the caller ignores the error, this can be a problem which may lead to a very subtle bug which may cause data corruption/inconsistency. For example: user, _ := getUser(...) // return (User, error)
// If there's an error, `user` will contain the zero value
// of `User`: `{"Id": 0, "Email": "", "Name": "", ...}`, which is garbage.
// So, if there's an error, the next line, which assumes there's no error returned by `getUser`,
// may lead to a subtle bug (e.g. data corruption):
doSomething(user) // Oops if `user` is a zero value
This is partly due to Go's weak type systems (no sum types) and partly due to Go's dangerous-and-may-lead-to-a-subtle-bug concept of zero values.Someone might argue that good programmers shouldn't ignore errors like this. True, but good languages should be designed such that bad practices should rarely happen, or at least require more conscious effort. For example, to do similarly to the previous example in Python, you need to write:
try:
user = get_user(...)
except: # Catch any exception
user = User()
do_something(user)
In Rust, you can do: let user = get_user(...).unwrap_or(User::new());
do_something(user);
In both languages, because there's no concept of zero values, you need to explicitly set a fallback/default value. While I understand why Go needs the concept of zero values (it treats errors as values but it doesn't have sum types), I think it does more harm than good. If a language treats errors as values, it'd better have sum types. if err != nil {
return err
}
No language is perfect and not all are an improvement on languages that have come before. Just admit it's rubbish and move on.Admit and move on is the status quo.
Not a rhetorical question; I really want to find out. Hackers, respond with your insights and speculations.
I think some sugar around this would be uncontroversial.
Ever heard of exceptions?
This is exactly what exceptions are made for. To allow you to write simple code but still make sure errors are propagated through your code.
It also makes it easier to write code that has to run something regardless of an error (using try/finally), while still ensuring that error is passed through your function reliably.
The question mark isn't necessary at all. Why would you pollute your code with additional character? It just says "I want to have a broken application that does not react to an error if I forget to put in a question mark"
I think a great article detailing why exceptions are not desirable is http://www.lighterra.com/papers/exceptionsharmful/ .
> The question mark isn't necessary at all. Why would you pollute your code with additional character? It just says "I want to have a broken application that does not react to an error if I forget to put in a question mark"
I'm no Rust expert, but I think the compiler will error if you don't put the question mark?
> The top three pain points for Go users, in surveys and direct feedback, have been consistent for a number of years. They are: package management, generics, and error handling. We are working on all three.
The first has been solved, the second is in the process of being solved, and the third has been addressed in two major proposals, both of which were rejected. I sympathize with the author's frustration, though I would argue that better error handling in Go is still being actively discussed and investigated.
https://twitter.com/_rsc/status/1146129898383302656
1. Package management has, more or less, been solved through minimum version selection in modules/vgo. Though not everyone's favorite, at least it doesn't require a SAT-solver (dependency hell is NP-complete https://research.swtch.com/version-sat)
https://github.com/golang/go/issues/24301
https://go.googlesource.com/proposal/+/master/design/24301-v...
https://research.swtch.com/vgo
https://github.com/golang/go/wiki/Modules
https://go.dev/blog/using-go-modules
https://golang.org/doc/tutorial/create-module
2. Parametric polymorphism/Type Parameters ("generics") is/are being introduced into the language in 1.18, which is slated for release in early 2022.
https://github.com/golang/go/issues/43651
https://go.googlesource.com/proposal/+/master/design/43651-t...
3. There have now been a couple of proposals to make error handling simpler and reduce boilerplate
https://github.com/golang/go/wiki/Go2ErrorHandlingFeedback
check/handle
https://go.googlesource.com/proposal/+/master/design/go2draf...
https://go.googlesource.com/proposal/+/master/design/go2draf...
try
https://github.com/golang/go/issues/32437
https://go.googlesource.com/proposal/+/master/design/32437-t...
https://news.ycombinator.com/item?id=20339697
https://news.ycombinator.com/item?id=20100902
https://news.ycombinator.com/item?id=20454966
related
https://go.googlesource.com/proposal/+/master/design/go2draf...
https://go.googlesource.com/proposal/+/master/design/go2draf...
https://go.googlesource.com/proposal/+/master/design/go2draf...
I start to enjoy those developers' cries realizing that error handling is part of the logic too. Sometimes even more important part.
Great job, Go.
Right, but I think the idea is that the programmer should think and decide every time whether that's what you want to do. I haven't programmed in Rust, but I suspect that people use the ? operator more than is ideal because it's so easy.
"Errors are values" means that this situation is not very different from "I just want to pass the result of my sqrt function up the stack" :) By reading code of function that returns the result of computation the next pair of eyes that gonna read this code will clearly see the intent and idea behind the code. The same should be with "unhappy path" – if intent was just to pass error, without caring of annotating it or doing something with it – it should be as clear and explicit as possible.
There is another benefit of "syntactic overhead" – incentive to improve the error handling code. I.e. I may not care at the beginning about annotating error, and just use "return err", but later on I want to make error messages more useful, so the "if err != nil {\n return err }\n" structure makes it easy to annotate it. Instead, if Go code was riddled with "?"s or "!"s or whatever syntactic sugar other use, I would not be so eager to switch from "concise and short single-char" to the "bloated 3 line".
Incentives matter in coding psychology. I wish more research was done on that.
Bottom line, hiding error handling has more long term drawbacks than benefits.
PS. I virtually never use "return err" anymore. Always at least annotate the error – it helps error messages readability immensely without resorting to adding wasteful stacktraces.
"Who cares? Shut up"
It’s in the context of concerns from newer developers who don’t focus on what’s important.
This is the same blog that gave us code smells, abstract more to hide it (https://jesseduffield.com/Type-Keys-Revisited) -- only to point out the possibly differing perspectives on software development.
There’s a philosophy to the language that’s very clearly defined. You have many options these days on what you write in. You’re not changing how Go does error handling because you think it’s needlessly verbose.
I would not represent that as anything else. The entire process is captured here: https://github.com/golang/proposal
At the very least, it's clear that the language maintainers see an issue with the current error handling, given how much time they've spent working on proposals. The community also clearly cares: see https://twitter.com/_rsc/status/1146129898383302656
Also, I see you amended your original comment with a jab against me for the type keys post. Not sure what to tell you there, I posted something, absorbed the feedback, and incorporated that into the blog. If you have specific issues with the latest post please let me know.
I didn't realize you were the same person who wrote that blog post, and I edited it to include it when I realized. I linked your amended version, not your initial version if that's of any worth.
And you're correct it's not closed. I still stand by that particular proposal is going nowhere. You can make any proposal you want. It hasn't even moved to the design stage yet in 3 years, so I'm not sure why it's something you hang on to as a signal error handling is changing. Take generics for example, and the years (decade) of work that it took.
This is unsolicited blog feedback:
Your blog and writing style doesn't read like it comes from a place of humility and learning, but from a place of authority. Sometimes that's OK, but in your case, feels unwarranted. And the constant push to get it on to HN or Reddit or N other platforms so it can spread... Thought leadership as an aspirational goal has never been something I look for in blogs I read (I recoil and go elsewhere when I sense this is the goal).
I understand it's hard work, and you want others to see it, but just someone else's perspective.
Some blogs that I really enjoy (and I hope others emulate so I'm sharing):
Software is never about the low level nitty gritty, and in these cases it's better to stick with what people expect to see / read. Don't make people think. I'm not smart enough to read your interesting error checking.