Go proposal: Type-safe error checking
76 points by cgrinds
76 points by cgrinds
Much better, but still bad.
I want to do an equivalent of rust's match:
switch t := err.(type) {
case net.OpError: // t is now net.OpError
...
}
But I suppose that wouldn't deal with the whole unwrapping idea.
And then:
errors.AsType[*net.OpError](err)
How would I have guessed that it's a pointer? I would have tried a pointer, but because I know that most errors behind an error are pointers, but not all are. Notice the word "try", we are once again in "not sure until you run it" territory.
Some tiny progress here, but overall go has painted itself into a corner (with the type system, not just error handling).
To be fair, Rust does not have a type match, so similar situations of type erasure or chaining are handled via chains of pattern matching a bunch of Error::downcast_ref.
Go does have type switches, but wrapping goes through a different mechanism entirely and unwrapping would have to be integrated into the language itself (which currently is not feasible and IIRC not needing to have the codegen interact with UDTs / interfaces is one of the reasons the core team went with internal iterators).
An other consideration is that Go's design uses both known error types and sentinel values. So if you're doing type testing on specific errors you need a mix of error types (As/AsType) and error values (Is) which can not be mixed. Incidentally switches is one area where AsType seems like a regression: with As you can write something like
var e1 Err1
var e2 Err2
switch {
case errors.Is(err, err1):
fmt.Println(err)
case errors.As(err, &e1):
fmt.Println(e1)
case errors.As(err, &e2):
fmt.Println(e2)
case errors.Is(err, err2):
fmt.Println(err)
}
which is verbose and obnoxious but works. I believe since a case clause has to be a single expression or value (or a comma-separated sequence thereof) there's no way to use AsType in that style.
Go does have type switches, but wrapping goes through a different mechanism entirely and unwrapping would have to be integrated into the language itself
A good indication that treating errors like special strings was a bad decision, and they should have been first-class members of the type system in the first place.
Sometimes choosing the "simple" option creates more complexity downstream, and you would've been better served by solving the hard problem up-front.
Go doesn't treat errors as strings.
The Error interface:
type error interface {
Error() string
}
How do you define an Error? With a string. How do you wrap an Error? A string.
I'm sure there's an internal representation in the compiler that is more complicated than that, but we're just splitting hairs at that point.
All error values must have an Error method which returns a printable string. That doesn't mean that error values are strings. For example, go look at https://pkg.go.dev/io/fs#PathError
As you can see there errors are always implemented as concrete structs, even wrapped errors. They are not built on top of strings: the string is just the human representation of that error. When you wrap an error you're creating a new struct and returning it, decorating the original error struct.
When errors accepted or returned from functions they usually do so in the form of error interfaces, which are basically tagged pointers.
It's not splitting hairs. A decision could've been made that all errors are defined by an interface { Error() []byte } and nothing in that implementation would've changed outside of the type of s and msg. They already are first-class members of the type system. What you want perhaps is a different type system which could afford unique behavior for errors.
I think a valid complaint here would be about error wrapping, not Error(). Wrapping is done entirely with reflection and uses of "anonymous interfaces", so it does read like a weird bypass of the type system. E.g.:
switch x := err.(type) {
case interface{ Unwrap() error }:
case interface{ Unwrap() []error }:
}
The definition in the docs can't be represented in types in a straightforward way (I guess?):
An error e wraps another error if e's type has one of the methods
Unwrap() errororUnwrap() []error.
I kind of agree with this, but if they named the interfaces it wouldn’t have made much of a difference. How would we implement wrapping without reflection?
Well, by that argument, the error interface didn't really need a name either, right? But it has one.
Though they're both filed under "wrapping", simple Unwrap() doesn't use reflection, just simple type assertions on the anonymous unwrap interfaces. It's Is() and As() that use reflection for the "DFS search of a tree of potentially multi-member errors that may be comparable and/or implement Is() and/or implement As()" algorithm. (Which does seem a bit much to represent in the Go type system!)
Well, by that argument, the error interface didn't really need a name either, right? But it has one.
I disagree. It seems reasonable to create a named type for error because it is used all over the place. The unwrappable types are really only used inside the error package, so it doesn’t really matter whether they are named or anonymous afaict?
https://cs.opensource.google/go/go/+/refs/tags/go1.25.5:src/errors/errors.go;l=9
// An error e wraps another error if e's type has one of the methods
//
// Unwrap() error
// Unwrap() []error
//
// If e.Unwrap() returns a non-nil error w or a slice containing w,
// then we say that e wraps w.
but then
https://cs.opensource.google/go/go/+/refs/tags/go1.25.5:src/errors/wrap.go;l=15
// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
//
// Unwrap only calls a method of the form "Unwrap() error".
Go does treat errors as strings a lot though.
Let's look at the tls library, let's say you have a tls certificate from a user and you call tls.X509KeyPair(userInput)
How can you distinguish no PEM data from unknown algorithm?
You do string matching. I know you might be saying "why bother distinguishing them, just print the string to your user as an error", but what if I want to localize the error message to the user's language, or log a metric about the prevalence of certain types of user errors?
If you grep for errors.New and fmt.Errorf in the go stdlib, you'll see that they're super prevalent, and for those errors you're pretty much stuck treating the errors exactly as strings and having to pattern match on them to localize or handle them.
FWIW if the instances were exposed as module-level values you could check for them using errors.Is. Custom error types (even exceptions in languages with that) would not fix anything if they weren’t exported.
You get the same crap in, say, Python when a package raises ValueError for everything.
Although it is very true that the standard library
io/fs does expose a number of error values and document them. So do archive/tar, archive/zip, and strconv.
mime is hilarious because it has a single exported error value and 5 non-exported error values.
This has nothing to do with Go's error handing though. Choose literally any language and I'll find you a similar example.
Go pushes you in the direction of stringly typed errors. You can't use a concrete error type in your function signature (i.e. os.Open always returns *os.PathError according to the docs, but the function signature is still error). This is because of how x, err := works, it re-assigns err if it was already declared before, and so if it was an interface before, it now becomes a "typed interface" meaning err != nil even for a (*os.PathError)(nil) type.
It also doesn't have enum matches, making typed errors less valuable, and the stdlib sets an example for using stringly typed errors.
Since I used go's tls library as an example, let's look at the most common tls library in Rust. You'll see that it has a typed error you can match on, and in Rust you see that a lot more because both the stdlib sets that example, and rust has the tools to make that work well (i.e. a functioning type-system where you can return concrete types, and exhaustive matching).
Feels like it's the language to me. I do admit that Go isn't wildly different from what you get in Javascript and python, but I think we should have a higher bar for a language that purports to be statically typed and have good error handling.
Almost every other language at least lets you return a concrete error type if you want (a checked exception in Java, etc), and Go doesn't even let you do that, so knowing what values you can put in errors.As really is just pure "read the docs (or usually code), and the compiler will not help you", while I struggle to think of another statically typed language with errors-as-values that doesn't let you actually encode error types in the type system.
I think a key point here is that situations with type erasure are vanishingly rare in idiomatic Rust code. Error types in Rust are generally Enums wrapping sub-errors (via libraries like thiserror). Whereas with Go, idiomatic error handling is type-erased.
This makes error handling in Rust significantly less verbose and more correct because the compiler easily checks for exhaustiveness in match statements, which can’t be done in Go.
Once you start wrapping errors in rust, it's not a simple match anymore either.
I want to do an equivalent of rust's match:
switch t := err.(type) {
case net.OpError: // t is now net.OpError
...
}
That's called "flow typing". It's quite useful.
I want to do an equivalent of rust's match:
switch t := err.(type) { case net.OpError: // t is now net.OpError ... }
I'd love something like this. To support Unwrap() error, though, it'd need to compile into a for-loop with a type-switch inside. Similar to how asType is implemented, but adjusted like:
// Replace inside loop...
if e, ok := err.(E); ok {
return e, true
}
// With...
switch e := err.(type) {
case E1:
case E2:
case E3:
}
The ergonomics would be better, at the cost of switch-case runtime impacts being harder to reason about.
How would I have guessed that it's a pointer? I would have tried a pointer, but because I know that most errors behind an error are pointers, but not all are. Notice the word "try", we are once again in "not sure until you run it" territory.
net.OpError would fail to compile as the error interface is only implemented on the pointer-receiver.
Your point is still valid, as it could be implemented on the value while functions return a pointer to it. This doesn't happen often in practice, but it's certainly not "airtight".
net.OpError would fail to compile as the error interface is only implemented on the pointer-receiver.
True, I stand corrected.
An example where it does compile with either pointer or value, as you pointed out:
if netErr, ok := ErrorAs[net.InvalidAddrError](err); ok {
fmt.Println("value net err:", netErr)
} else if netErr, ok := ErrorAs[*net.InvalidAddrError](err); ok {
fmt.Println("pointer net err:", netErr)
} else {
fmt.Println("Unknown error")
}
E.g.: public error type URNPrefixError in google/uuid package, which is the de facto standard.
More exhibits: syscall Errno, net UnknownNetworkError, net InvalidAddrError, go/scanner Error and ErrorList, redis/go-redis RedisError.
The problem for these error types is that it looks like they're handled when using this newest addition to the family, AsType[*uuid.URNPrefixError](err), no compiler error, but this will never match. And there's no way to guess that it wouldn't, or that I should use AsType[uuid.URNPrefixError](err).
How would I have guessed that it's a pointer? I would have tried a pointer, but because I know that most errors behind an error are pointers, but not all are. Notice the word "try", we are once again in "not sure until you run it" territory
Presumably it's a compiler error, because net.OpError doesn't implement the error interface and thus would fail the generic constraint check ([E error]).
net.OpError does implement error with a pointer receiver, but sometimes error is implemented with a value receiver.
That’s because if T satisfies the error interface, then so does *T—both are potential error implementations (because pointer types inherit the methods associated with their value type).
Example of using ErrorAs as both pointer and value, no compiler error:
var err error = net.UnknownNetworkError("zzz")
if t, ok := errors.AsType[*net.UnknownNetworkError](err); ok {
println("it was a pointer at runtime: " + *t)
} else if t, ok := errors.AsType[net.UnknownNetworkError](err); ok {
println("it was a value at runtime: " + t)
}
Right, both of those types implement the error interface. Why would the compiler throw a type error for a type that satisfies the generic constraint? If anything is the issue here, it’s the use of structural subtyping (interfaces) for errors rather than something more explicit—I don’t think AsType() has anything to do with it.