Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

> Returning bogus values alongside errors.

Unless documented otherwise, a non-nil error renders all other return values invalid, so there's no real sense of a "bogus value" alongside a non-nil error.

> Designing the error mechanism based on the assumptions that errors are primarily meant to be logged and that you have to get out of your way to develop errors that can actually be handled

I don't see how any good-faith analysis of Go errors as specified/intended by the language and its docs, nor Go error handling as it generally exists in practice, could lead someone to this conclusion.




> I don't see how any good-faith analysis of Go errors as specified/intended by the language and its docs, nor Go error handling as it generally exists in practice, could lead someone to this conclusion.

Let me detail my claim.

Broadly speaking, in programming, there are three kinds of errors:

1. errors that you can do nothing about except crash;

2. errors that you can do nothing about except log;

3. errors that you can do something about (e.g. retry later, stop a different subsystem depending on the error, try something else, inform the user that they have entered a bad url, convert this into a detailed HTTP error, etc.)

Case 1 is served by `panic`. Case 2 is served by `errors.New` and `fmt.Errorf`. Case 3 is served by implementing `error` (a special interface) and `Unwrap` (not an interface at all), then using `errors.As`.

Case 3 is a bit verbose/clumsy (since `Unwrap` is not an interface, you cannot statically assert against it, so you need to write the interface yourself), but you can work with it. However, if you recall, Go did not ship with `Unwrap` or `errors.As`. For the first 8 years of the language, there was simply no way to do this. So the entire ecosystem (including the stdlib) learnt not to do it.

As a consequence, take a random library (including big parts of the stdlib) and you'll find exactly that. Functions that return with `errors.New`, `fmt.Errorf` or just pass `err`, without adding any ability to handle the error. Or sometimes functions that return a custom error (good) but don't document it (bad) or keep it private (bad).

Just as bad, from a (admittedly limited) sample of Go developers I've spoken to, many seem to consider that defining custom errors is black magic. Which I find quite sad, because it's a core part of designing an API.

In comparison, I find that `if err != nil` is not a problem. Repeated patterns in code are a minor annoyance for experienced developers and often a welcome landscape feature for juniors.


Again, you don't need to define a new error type in order to allow callers to do something about it. Almost all of the time, you just need to define an exported ErrFoo variable, and return it, either directly or annotated via e.g. `fmt.Errorf("annotation: %w", ErrFoo)`. Callers can detect ErrFoo via errors.Is and behave accordingly.

`err != nil` is very common, `errors.Is(err, ErrFoo)` is relatively uncommon, and `errors.As(err, &fooError)` is extraordinarily rare.

You're speaking from a position of ignorance of the language and its conventions.


Indeed, you can absolutely handle some cases with combinations of `errors.Is` and `fmt.Errorf` instead of implementing your own error.

The main problem is that, if you recall, `errors.Is` also appeared 8 years after Go 1.0, with the consequences I've mentioned above. Most of the Go code I've seen (including big parts of the standard library) doesn't document how one could handle a specific error. Which feeds back to my original claim that "errors are primarily meant to be logged and that you have to get out of your way to develop errors that can actually be handled".

On a more personal touch, as a language designer, I'm not a big fan of taking an entirely different path depending on the kind of information I want to attach to an error. Again, I can live with it. I even understand why it's designed like this. But it irks the minimalist in me :)

> You're speaking from a position of ignorance of the language and its conventions.

This is entirely possible.

I've only released a few applications and libraries in Go, after all. None of my reviewers (or linters) have seen anything wrong with how I handled errors, so I guess so do they? Which suggests that everybody writing Go in my org is in the same position of ignorance. Which... I guess brings me back to the previous points about error-fu being considered black magic by many Go developers?

One of the general difficulties with Go is that it's actually a much more subtle language than it appears (or is marketed as). That's not a problem per se. In fact, that's one of the reasons for which I consider that the design of Go is generally intellectually pleasing. But I find a strong disconnect between two forms of minimalism: the designer's zen minimalism of Go and the bruteforce minimalism of pretty much all the Go code I've seen around, including much of the stdlib, official tutorials and of course unofficial tutorials.


> Indeed, you can absolutely handle some cases with combinations of `errors.Is` and `fmt.Errorf` instead of implementing your own error.

Not "some cases" but "almost all cases". It's a categorical difference.

> Most of the Go code I've seen (including big parts of the standard library) doesn't document how one could handle a specific error. Which feeds back to my original claim that "errors are primarily meant to be logged and that you have to get out of your way to develop errors that can actually be handled".

First, most stdlib APIs that can fail in ways that are meaningfully interpret-able by callers, do document those failure modes. It's just that relatively few APIs meet these criteria. Of those that do, most are able to signal everything they need to signal using sentinel errors (ErrFoo values), and only a very small minority define and return bespoke error types.

But more importantly, if json.Marshal fails, that might be catastrophic for one caller, but totally not worth worrying about for another caller. Whether an error is fatal, or needs to be introspected and programmed against, or can just be logged and thereafter ignored -- this isn't something that the code yielding the error can know, it's a decision made by the caller.


> Not "some cases" but "almost all cases". It's a categorical difference.

Good point. But my point remains.

> First, most stdlib APIs that can fail in ways that are meaningfully interpret-able by callers, do document those failure modes. It's just that relatively few APIs meet these criteria. Of those that do, most are able to signal everything they need to signal using sentinel errors (ErrFoo values), and only a very small minority define and return bespoke error types. > > But more importantly, if json.Marshal fails, that might be catastrophic for one caller, but totally not worth worrying about for another caller. Whether an error is fatal, or needs to be introspected and programmed against, or can just be logged and thereafter ignored -- this isn't something that the code yielding the error can know, it's a decision made by the caller.

I may misunderstand what you write, but I have the feeling that you are contradicting yourself between these two paragraphs.

I absolutely agree that the code yielding the error cannot know (again, with the exception of panic, but I believe that we agree that this is not part of the scope of our conversation). Which in turn means that every function should document what kind of errors it may return, so that the decision is always delegated to client code. Not just the "relatively few APIs" that you mention in the previous paragraph.

Even `text.Marshal`, which is probably some of the most documented/specified piece of code in the stdlib, doesn't fully specify which errors it may return.

And, again, that's just the stdlib. Take a look at the ecosystem.


> I absolutely agree that the code yielding the error cannot know (again, with the exception of panic, but I believe that we agree that this is not part of the scope of our conversation). Which in turn means that every function should document what kind of errors it may return, so that the decision is always delegated to client code.

As long as the function returns an error at all, then "the decision [as to how to handle a failure] is always delegated to client [caller] code" -- by definition. The caller can always check if err != nil as a baseline boolean evaluation of whether or not the call failed, and act on that boolean condition. If err == nil, we're good; if err != nil, we failed.

What we're discussing here is how much more granularity beyond that baseline boolean condition should be expected from, and guaranteed by, APIs and their documentation. That's a subjective decision, and it's up to the API code/implementation to determine and offer as part of its API contract.

Concretely, callers definitely don't need "every function [to] document what kind of errors it may return" -- that level of detail is only necessary when it's, well, necessary.


> Unless documented otherwise, a non-nil error renders all other return values invalid, so there's no real sense of a "bogus value" alongside a non-nil error

But you have to return something to satisfy the function signature's type, which often feels bad.

>> Designing the error mechanism based on the assumptions that errors are primarily meant to be logged and that you have to get out of your way to develop errors that can actually be handled

> I don't see how any good-faith analysis of Go errors as specified/intended by the language and its docs, nor Go error handling as it generally exists in practice, could lead someone to this conclusion.

I agree to a point, but if you look at any random Go codebase, they tend to use errors.New and fmt.Errorf which do not lend themselves to branching on error conditions. Go really wants you to define a type that you can cast or switch on, which is far better.


> Go really wants you to define a type that you can cast or switch on, which is far better.

Go very very much does not want application code to be type-asserting the values they receive. `switch x.(type)` is an escape hatch, not a normal pattern! And for errors especially so!

> they tend to use errors.New and fmt.Errorf which do not lend themselves to branching on error conditions

You almost never need to branch on error conditions in the sense you mean here. 90% of the time, err != nil is enough. 9% of the time, errors.Is is all you need, which is totally satisfied by fmt.Errorf.


> 90% of the time, err != nil is enough

Only if your only desire is to bubble the error up and quite literally not handle it at all.

If you want to actually handle an error, knowing what actually went wrong is critical.


Returning an error -- or, more accurately, identifying an error and returning an annotation or transformation of that error appropriate for your caller -- is a way of handling it. The cases where, when your code encounters an error, that it can do anything other than this are uncommon.


This goes completely against the golang error-handling mindset.

Error handling is so important, we must dedicate two-thirds of the lines of every golang program to it. It is so important that it must be made a verbose, manual process.

But there's also nothing that can be done about most errors, so we do all this extra work only to bubble errors up to the top of the program. And we do all this work as a human exception-handle to build up a carefully curated manual stack trace that loses all the actually-useful elements of a stack trace like filenames and line numbers.


This is just nonsense.

Handling errors this way is possible in only very brittle and simplistic software.

I mean, you're contradicting your very own argument. If this was the primary/idiomatic way of handling errors... then Go should just go the way of most languages with Try/Catch blocks. If there's no valuable information or control flow to managing errors... then what's the point of forcing that paradigm to be so verbose and explicit in control flow?

None.


> Go very very much does not want application code to be type-asserting the values they receive. `switch x.(type)` is an escape hatch, not a normal pattern! And for errors especially so!

A type assert/switch is exactly how you implement Error.Is [^0] if you define custom error types. Sure it's preferable to use the interface method in case the error is wrapped, but the point stands. If you define errors with Errors.New you use string comparison, which is only convenient if you export a top level var of the error instead of using Errors.New directly.

> You almost never need to branch on error conditions in the sense you mean here. 90% of the time, err != nil is enough. 9% of the time, errors.Is is all you need, which is totally satisfied by fmt.Errorf.

I'd argue it's higher than 9% if you're dealing with IO, which most applications will. Complex interfaces like HTTP and filesystems will want to retry on certain conditions such as timeouts, for example. Sure most error checks by volume might be satisfied with a simple nil check, it's not fair to say branching on specific errors is not common.

[0]: The documentations own example of implementing Error.Is uses a switch. https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/...

It happens to be a syscall interface so errors are reported as numbers.


> If you define errors with Errors.New you use string comparison.

With `Errors.New`, you're expected to provide a human-readable message. By definition, this message may change. Relying on this string comparison is a recipe for later breakages. But even if it worked, this would require documenting the exact error string returned by the function. Have you _ever_ seen a function containing such information in the documentation?

As for `switch x.(type)`, it doesn't support any kind of unwrapping, which means that it's going to fail if someone in the stack just decides to add a `fmt.Errorf` along the way. So you need all the functions in the stack to promise that they're never going to add an annotation detailing what the code was doing when the error was raised. Which is a shame, because `fmt.Errorf` is often a good practice.


I was actually referring to the implementation of errors.Is, which uses string comparison internally if you use the error type returned by errors.New and a type cast or switch if you use a custom type (or the cases where the stdlib defines a custom error type).


errors.Is definitely doesn't use string comparison internally:

https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/...


Of course it does. What do you think an errors.New contains and how do you figure it compares by value when checking placeholder errors?

The answer is that errors.New just wraps the error message in an errorString struct, and the second line of `is` is a string comparison.



Oh hell, I always forget equality doesn't go through pointers in go, you're right.

And as you demonstrate I could have tested it easily enough to confirm I was reading the code correctly...


My bad, I misunderstood your previous comment.


> A type assert/switch is exactly how you implement Error.Is [^0]

errors.Is is already implemented in the stdlib, why are you implementing it again?

I know that you can implement it on your custom error type, like your link shows, to customize the behavior of errors.Is. But this is rarely necessary and generally uncommon..

> If you define errors with Errors.New you use string comparison, which is only convenient if you export a top level var of the error instead of using Errors.New directly.

What? If you want your callers to be able to identify ErrFoo then you're always going to define it as a package-level variable, and when you have a function that needs to return ErrFoo then it will `return ErrFoo` or `return fmt.Errorf("annotation: %w", ErrFoo)` -- and in neither case will callers use string comparison to detect ErrFoo, they'll use errors.Is, if they need to do so in the first place, which is rarely the case.

This is bog-standard conventional and idiomatic stuff, the responsibility of you as the author of a package/module to support, if your consumers are expected to behave differently based on specific errors that your package/module may return.

> Complex interfaces like HTTP and filesystems will want to retry on certain conditions such as timeouts, for example. Sure most error checks by volume might be satisfied with a simple nil check, it's not fair to say branching on specific errors is not common.

Sure, sometimes, rarely, callers need to make decisions based on something more granular than just err != nil. In those minority of cases, they usually just need to call errors.Is to check for error identity, and in the minority of those minority of cases that they need to get even more specific details out of the error to determine what they need to do next, then they use errors.As. And, for that super-minority of situations, then sure, you'd need to define a FooError type, with whatever properties callers would need to get at, and it's likely that type would need to implement an Unwrap() method to yield some underlying wrapped error. But at no point are you, or your callers, doing type-switching on errors, or manual unwrapping, or anything like that. errors.As works with any type that implements `Error() string`, and optionally `Unwrap() error` if it wants to get freaky.


> Unless documented otherwise, a non-nil error renders all other return values invalid, so there's no real sense of a "bogus value" alongside a non-nil error.

Ah yes the classic golang philosophy of “just avoid bugs by not making mistakes”.

Nothing stops you from literally just forgetting to handle ann error without running a bunch of third party linting tools. If you drop an error on the floor and only assign the return value, go does not care.


I know..! Ignoring an error at a call site is a bug by the caller, that Go requires teams to de-risk via code review, rather than via the compiler. This is well understood and nobody disputes it. And yet all available evidence indicates it's just not that big of a deal and nowhere near the sort of design catastrophe that critics believe it to be. If you don't care or don't believe the data that's fine, everyone knows your position and knows how dumb you think the language is.


(not the GP)

Indeed, while not being a fan of how this aspect of Go, I have to admit that it seldom causes issues.

It is, however, part of the reasons for which you cannot attach invariants to types in Go, which is how my brain works, and probably the main reasons for which I do not enjoy working with Go.


Yeah, I mean, Go doesn't see types as particularly special, rather just as one of many tools that software engineers can leverage to ship code that's maintainable and stands the test of time. If your mindset is type-oriented then Go is definitely not the language for you!


> all available evidence

Where is this evidence? Where is the data that I am supposed to believe?




Consider applying for YC's Fall 2025 batch! Applications are open till Aug 4

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: