iamcalledrob
a month ago
I'd encourage the author to spend more time learning Go. They've come to incorrect conclusions -- especially regarding errors. Read more of the stdlib to see how powerful they can be, e.g. net.OpError: https://cs.opensource.google/go/go/+/refs/tags/go1.25.5:src/...
> The user now has an interface value error that the only thing
> they can do is access the string representation of ... The only
> resort the consumer of this library has is to parse the string
> value of this error for useful information.
This shows a lack of understanding about the `error` interface, `errors.Is`, `errors.As`, error wrapping etc.Personally, I think Go errors are fantastic, just the right sprinkling of structure on top of values. And panic/recover for truly exceptional circumstances.
logicprog
a month ago
Yeah, I'm brand new to learning Go, but that was the first thing I thought when I got to his section on errors. You're supposed to return a generalized error and then use errors.is or errors.as to figure out what specific type of error it is so that you can access the specific data on it. And the reason you do it that way instead of just returning a concrete error type is because one function might want to return different error types depending on what specific thing happened. Just like you can throw different exception types in other languages. And obviously if that's happening, the reason you can't just directly access stuff without doing errors.is or as is because you don't know which it is until you do that.
I think it's a shame that Go doesn't have sum types, exhaustiveness checking, and pattern matching, because it would make its error model more enforceable and concise (see Gleam!) but given that it doesn't for whatever reason, I think the solution it's gone with is genuinely the best possible second option that gives you 90% of the benefits of result and option (out of band errors, structured error data, errors as values so you can directly and in line decide what to do with them without special control flow, no invisible or non-local control flow, it's immediately obvious when anything can return an error, etc).
And I say this as someone who started out hating go.
ljm
a month ago
I think Go's concept of error wrapping is probably unusual to newcomers to the language who might be used to, say, pulling in a dependency for error handling (logrus or whatever) when it's all there in the stdlib in what Go has decided to be the idiomatic way to do errors and logging.
It's nice when you understand how to do it well and move on from, say, printing errors directly where they happen rather than creating, essentially, a stack of wrapped errors that gets dumped at an interface boundary, giving you much more context.
dematz
a month ago
I think it's probably confusing until you understand interfaces, which coming from other languages you might not be familiar with (my guess for what happened in this blog post). If you don't know what an interface is then maybe you assume err.Error() is some kind of string, without realizing Error() is just a required function but the err could be whatever type.
benrazdev
a month ago
My understanding is that using `error` as a return type performs type erasure and that the only information given to the caller is the value of the `e.Error()` function. I now understand that this isn't the case.
bccdee
a month ago
You're right, but I still think they have a point. What I miss from `error.Is/As` is exhaustive matching. I'd love a way to statically guarantee I haven't missed an important error type. It really comes back to the absence of sum types.
packetlost
a month ago
Yeah, the lack of sum types of any kind in Go is the only thing that I really miss when coming back from Rust. It's, I think, a big part of the reason that Gleam has seen a lot of growth. It has many of the syntactic benefits of Go with the compiler guarantees of Rust (though it's weird in some other ways, like dramatically favoring continuation-passing-style in programmer facing syntax).
9rx
a month ago
> I'd love a way to statically guarantee I haven't missed an important error type.
It wouldn't be hard — rather easy, even — to write a static analyzer for that if you constrained your expectations to cases where sum types could practically be used. No reason to not do it right now!
But sum types don't really solve for a lot of cases. Even in Go you can add additional constraints to errors to get something approaching sum types. You don't have to use a naked `error`. But you are bound to start feeling pain down the road if you try. There is good reason why even the languages that support defined error sets have trended back to using open-ended constructs.
It does sound great in theory, no doubt, but reality is not so kind.
bccdee
a month ago
> There is good reason why even the languages that support defined error sets have trended back to using open-ended constructs.
Rust does it that way & has never trended in any other direction. The generally-accepted wisdom is "thiserror¹ for libraries, anyhow² for applications"—i.e., any error that's liable to be handled by a machine should be an enum of exhaustively-matchable error kinds, whereas anything that's just being propagated to the user should be stringified & decorated with context as it bubbles up.
Certainly, unified error types which wrap and erase more specific errors are sometimes desirable, but equally they often are not. Languages which support exhaustive matching support both, permitting us to choose based on context.
user
a month ago
user
a month ago
Ferret7446
a month ago
Exhaustive matching is terrible for backward/forward compatibility.
bccdee
a month ago
Yeah, your error handling should not always be backwards/forwards compatible. If the new version of a library is throwing a new kind of error, that library's clients need to be updated. Being able to break your clients when necessary is a feature & not a bug. Of course, if you don't need to break your clients, simply don't change your ErrorKind enum.
Backwards/forwards compatibility matters a lot across service boundaries. I'm not saying Protobuf needs to be exhaustively matchable. But native types from a static library? You want to be able to match those exhaustively.
iamsomewalrus
a month ago
I use Go as my preferred language and I think the author is mostly right.
There’s no way for me to know or even check what are the possible errors this function can return? Sure, sometimes a comment in the library might be illuminating, but sometimes not.
I agree that errors as values that I can handle at the call site rarely feel useful.
Some of the Is, As ergonomics have improved, but damn if coding agents don’t love to just fmt.error any code it writes. Thus hiding the useful information about the error in a string.
tptacek
a month ago
The article claims:
"The user now has an interface value error that the only thing they can do is access the string representation of."
This is false. Didn't used to be, but that was many years ago.
benrazdev
a month ago
>this is false.
It's mostly false.
Technically, if you use `fmt.Errorf`, then the caller can't get anything useful out of your errors.
Types are promises. All the `error` interface promises is a string representation. I acknowledge that most of the time there is more information available. However, from the function signature _alone_, you wouldn't know. I understand that this is more of a theoretical problem then a practical problem but it still holds (in my opinion).
tptacek
a month ago
The subtlety missed in these conversations is that almost all the information there is for typical error handling --- that is, all the information that would be present in a typical Rust error handling scenario as well --- is encoded in the type tree of the errors.
(Rust then improves drastically on the situation with pattern matching, which would simply improve Go with no tradeoffs I can really discern, just so we're clear that I'm not saying Go's error story is at parity with Go. But I'd also point out that Rust error handling at a type level is kind of a painful mess as well.)
benrazdev
a month ago
>that is, all the information that would be present in a typical Rust error handling scenario as well --- is encoded in the type tree of the errors.
it's only present when you downcast though?
tptacek
a month ago
It's not super ergonomic, but the information is there, in about the same density as it would be in a Rust app, supporting the same error handling strategies (ie, discriminating between transient and durable errors, &c).
lokar
a month ago
I feel that in general, in the past 20+ odd years there has been an over emphasis on complex control flow with errors.
Lots of different fine grained error types with complex logic spread out over several call layers.
ime it’s better to aim for simpler handling, which seems to match go
tptacek
a month ago
In fairness, the idea that you needed to scrape error strings to handle errors was a very popular complaint about Go that was valid 10 years ago, including in the standard library.
wrenky
a month ago
They are an improvement over most languages, but the are _not_ better than sum types for the reasons listed above. Way less flexible and forces a lot more verbosity when compared to more functional languages.
Hes definitely wrong on how the error types can help account for that but it would be best in class if we could properly chain them AND use all the greatness from interfaces.
kllrnohj
a month ago
The author is clearly aware of `error.Is` as they use it in the snippet they complain about. The problem is Go's errors are not exhaustive, the equivalent to ENOTDIR does not exist. So you can't `errors.Is` it. And while Stat does tell you what specific error type it'll be in the documentation, that error type also doesn't have the error code. Just more strings!
Is this a problem with Go the language or Go the standard library or Go the community as a whole? Hard to say. But if the standard library uses errors badly, it does provide rather compelling evidence that the language design around it wasn't that great.
yencabulator
a month ago
> the equivalent to ENOTDIR does not exist
nirui
a month ago
> e.g. net.OpError
I have my own complain against Rust's error handlings, but I can't bring myself to praise what Go has been doing.
One problem with the error interface is that you can't know exactly which error type will be returned, and the Go authors may add new error types form time to time. `net.OpError` itself is a great example for that, which is a newer type than net.Error.
This style of error signalling is best used when the error handling is binary, either "No error, continue" or "Errored out, abort", but not branched out path like "If encountered error A, do this. If encountered error B, do that. Otherwise, abort".
Rust's error handling encourages such branched out handlings by default, but if you want to so the same in Go, there will be a nightmarish manual type discovery and tracking operation waiting for you.
So for me, I rather use a dedicated signal code to tell which error branch I should do next rather than relying on the error, i.e:
if next, err := func(); err != nil { // If errored, abort it
return err
} else if next == condition1 {
return op1()
} else if next == condition2 {
return op2()
} else {
panic("unsupported condition")
}iamcalledrob
a month ago
> This style of error signalling is best used when the error handling is binary,
> either "No error, continue" or "Errored out, abort", but not branched
> out path like "If encountered error A, do this. If encountered error B,
> do that. Otherwise, abort".
If I'm understanding you correctly, you can implement your example in a fully idiomatic way, and the stdlib does this in a bunch of places.Errors are just values, so you can return whatever best suits the use-case. The gamut of expected errors might not be part of type signature, but it can be part of your documentation.
The "signal code" you refer to could be implemented via:
1. Returning error structs (e.g. ErrFoo, ErrBar) and using `errors.As()` access those structs -- useful if you have a highly structured error with additional fields.
type ErrFoo struct { // fields // }
type ErrBar struct { // fields // }
if var errFoo *ErrFoo; errors.As(err, &errFoo) {
// handle ErrFoo
}
if var errBar *ErrBar; errors.As(err, &errBar) {
// handle ErrBar
}
2. Returning a single error struct (MyErr) with an `Op` field, as `net.OpError` does. Similarly accessing via `errors.As()` // top-level, e.g. exported by package
type MyErrorStruct struct {
Op string
Path string
}
var (
OpFoo string = "foo"
OpBar string = "bar"
)
var opErr *MyErrorStruct
if errors.As(err, &opErr) {
if opErr.Op = OpFoo {
// handle OpFoo at path opErr.Path
}
if opErr.Op = OpBar {
// handle OpBar at path opErr.Path
}
}
3. Returning predefined errors, e.g. `var ErrFoo = errors.New("foo")` and checking for them via `errors.Is()` // top-level, e.g. exported by package
var ErrFoo = errors.New("foo")
var ErrBar = errors.New("bar")
if errors.Is(err, ErrFoo) {
// handle ErrFoo
}
if errors.Is(err, ErrBar) {
// handle ErrBar
}
Sidenote: I feel like people struggle to internalise that Go errors are just values* and are therefore are wildly flexible, yet not special in any way.I wonder if this is because other languages treat errors as something special?
yencabulator
a month ago
> Sidenote: I feel like people struggle to internalise that Go errors are just values* and are therefore are wildly flexible, yet not special in any way.
I think it's more they haven't used Go interfaces much.
I think most programming languages these days have errors-are-values, e.g. Rust Result transports a normal value, Python & Javascript exceptions are just values even when they are carried over a wildly different control flow.
(The argument for sum types and exhaustiveness is valid, but most languages don't have those so focusing that critique on Go is misguided. They are nice in Rust.)
nirui
a month ago
> If I'm understanding you correctly, you can implement your example in a fully idiomatic way, and the stdlib does this in a bunch of places.
I don't think you understood my point. Instead, I believe you launched your argument too quickly. Because if you continue reading, you'll see:
> ... but if you want to so the same in Go, there will be a nightmarish manual type discovery and tracking operation waiting for you.
Which should deter you from providing the example where you declared the `ErrFoo` and `ErrBar` type, since it's basically the "nightmarish manual type discovery" scenario unfolding in basically realtime.
But let's continue with the example to make my argument more full:
Let's say one day the `ErrFoo` and `ErrBar` type is not sufficient, so you declares another type `Err2000`. Now what happens down stream?
Since the some function may just return an error interface, there's no way for the end user to know you've added that error type. From their prospective, everything is unchanged, and still compiles just fine.
If the user's code is relying on the exact error type for branching, then they have to perform "manual type discovery and tracking" to monitor the changes to your code (and everything above it, really) to ensure that the branching is still done correctly.
In Rust however, since error is a emun, all error conditions must be handled or explicitly ignored during a `match`, if you write something like
match File::open("filename.txt") {
Ok(file) => {}
Err(io_err) => match io_err.kind() {
ErrorKind::IsADirectory => {} // Notice how not every error type is handled
},
}
The compiler will just stand up and make your ass weep. Meaning if you add some new error conditions, your user will know that for sure when they compile.(BTW, `std::io::ErrorKind` is indeed `non_exhaustive`, but you the maker of the error type still have to explicitly/intentionally declare it to make it non_exhaustive)
So, let's read my comment again:
> Rust's error handling encourages such branched out handlings by default, but if you want to so the same in Go, there will be a nightmarish manual type discovery and tracking operation waiting for you.
(Also, "by default" is there to indicate that yeah you can boxing a `std::error:Error` trait in Rust, then you ended up with something like how Go handles error. I know.)
iamcalledrob
a month ago
> nightmarish manual type discovery
Your criticism here is of a lack of Sum-types in Golang, not the approach to errors as values. It's just a different philosophy to Rust.Go maintains a strict backwards-compatibility guarantee (*love* this), and using sum types for errors would mean, at least in the stdlib, either (a) no new errors can be introduced, or (b) new versions of Go would break builds when new errors are introduced.
> Since the some function may just return an error interface, there's
> no way for the end user to know you've added that error type. From
> their prospective, everything is unchanged, and still compiles just fine.
Personally, I value my code compiling in the future over more explicit error handling. New errors will hit my catch-all branch, and I can special-case them later as I see fit. But it's a philosophy/values thing.As an aside, I very rarely find myself actually wanting a sum type for errors. I usually want to check for a few specific errors, but I almost always want a catch-all for truly unexpected stuff. Sum typed errors in any complex system often end up needing a `.other()`-style case anyway because in the real world so much can go wrong.
Rust is by no means perfect either. The `?` operator steers you in the direction of ignoring errors rather than thinking about them, which I think leads to worse outcomes (i.e. that Cloudflare outage)
nirui
22 days ago
I think we've wasted a lot of time on this.
> Personally, I value my code compiling in the future over more explicit error handling. New errors will hit my catch-all branch, and I can special-case them later as I see fit. But it's a philosophy/values thing.
OR, you can just do `fn() MyError` then `if myError := fn(); !myError.OK()` opposite to just `fn() error`. I actually use this "trick" on many of my projects and they worked great.
I'm not really sure why Go defenders MUST praise Go's error handling as if it's flawless and thus all fault signals must be a `error`. As I pointed out few comments back, Go is clearly promoting binary error handling i.e. `if err != nil { return error }`, as doing branched out handling for specific error type is much harder (one example is the "nightmarish manual type discovery" mentioned above), so it's far from flawless. It's useful, sure, and people are using it, but it's not flawless.
BTW:
> I can special-case them later as I see fit
See? You already doing manual type discovery, they slipped the idea so naturally into your brain you don't even notice the struggle. But what if the number of types that you need to take care of keeps growing? Is it scalable?
You know what, maybe they should add Sum type for error handling. Hope it's not too late now since many people already camped on the idea that returning an interface then and casting it for error handling is the best idea ever.
iamcalledrob
a month ago
Sidenote: if you really need compile-time safety for errors, you can always use your function signature to achieve this.
func DoThing(onFoo func(), onBar func()) { ... }
It's a bit horrible, but does solve for the rare occasion when certain scenarios must be handled, and makes it clear that the change is a breaking one. Personally never needed this though.the_gipsy
a month ago
The author is wrong, there exist mechanisms to cast errors. But they all suck! It's all just guessing and grepping for -hopefully- unique error messages.