Currently in 1.18 and before, when using the errors.As
method, an error type you would like to write into must be predeclared before calling the function. For example:
var myErr *MyCustomError
if errors.As(err, &myErr) {
// handle myErr
}
This can makes control flow around handling errors "unergonomic".
I'd propose that a new, type parameterized method be added to the errors
package in 1.19:
func IsA[T error](err error) (T, bool) {
var isErr T
if errors.As(err, &isErr) {
return isErr, true
}
var zero T
return zero, false
}
This enables more "ergonomic" usage as follows:
err := foo()
if err != nil {
if myErr, ok := errors.IsA[*MyCustomError](err); ok {
// handle myErr
} else if otherErr, ok := errors.IsA[*OtherError](err); ok {
// handle otherErr
}
// handle everything else
}
instead of
err := foo()
if err != nil {
var myErr *MyCustomError
if errors.As(err, &myErr) {
// handle customer error
}
var otherErr *OtherError
if errors.As(err, &otherErr) {
// handle other error
}
// handle everything else
}
````
This change would reduce the overall LOC needed for handling custom errors, imo improves readability of the function, as well as scopes the errors to the `if` blocks they are needed in.
Naming is hard so `IsA` might be better replaced with something else.
**Comment From: seankhliao**
Interesting, but changing the signature of `errors.As` is not a backwards compatible change.
It will have to be a new function.
**Comment From: slnt**
> Interesting, but changing the signature of `errors.As` is not a backwards compatible change. It will have to be a new function.
Yeah, unfortunate. I think `if customerErr, ok := errors.IsA[*CustomError](err); ok` reads quite nicely. It would be implemented pretty simply in terms of `errors.As`:
```go
func IsA[T error](err error) (T, bool) {
var isErr T
if errors.As(err, &isErr) {
return isErr, true
}
var zero T
return zero, false
}
Comment From: ianlancetaylor
CC @jba @neild
(My vague recollection is that this was considered and rejected when errors.As
was introduced, even beyond the fact that at the time type parameters did not yet exist.)
Comment From: neild
@rsc made the case in the original proposal issue that a type-parameterized version of errors.As
would not be an improvement:
https://github.com/golang/go/issues/29934#issuecomment-490091428
@dsnet also pointed out that the non-type-parameterized version of As
can be used in a switch, while the type-parameterized one cannot:
https://github.com/golang/go/issues/29934#issuecomment-460093518
I don't recall if there were any other arguments against a type-parameterized As
(aside from, obviously, the fact that type parameters didn't exist at the time).
I personally think that a type-parameterized As
seems fairly reasonable, although the practical benefit over the current As
seems small. It would need a good name. I don't know what that name would be. IsA
does not seem right. Under the current design, "Is" is an enhanced form of equality and "As" is an enhanced form of type assertion; blurring that distinction would add confusion.
An argument against adding a type-parameterized As
is that the benefit does not justify the cost in API churn and user confusion. I don't have a strong opinion on whether the benefits do outweigh the costs.
Comment From: DeedleFake
I personally think that a type-parameterized As seems fairly reasonable, although the practical benefit over the current As seems small. It would need a good name. I don't know what that name would be. IsA does not seem right. Under the current design, "Is" is an enhanced form of equality and "As" is an enhanced form of type assertion; blurring that distinction would add confusion.
AsA()
could work, though it's a little oddly close to the existing one. if pe, ok := errors.AsA[*os.PathError](err); ok { ... }
reads pretty well to me, though it should technically be 'an *os.PathError
', not 'a'.
New proposal: Automatically alias all functions with names that match ([a-z0-9])A$
to ${1}An
.
Comment From: zigo101
I like this proposal, but I fell the call errors.IsA[*MyCustomError](err)
is not very natural.
If would be good if we could pass type arguments to unnamed value parameters (_
) of type parameter types.
For example,
func IsA[T error](err error, _ T) (T, bool) {
var isErr T
if errors.As(err, &isErr) {
return isErr, true
}
var zero T
return zero, false
}
err := foo()
if err != nil {
if myErr, ok := errors.IsA(err, MyCustomError); ok {
// handle myErr
} else if otherErr, ok := errors.IsA(err, OtherError); ok {
// handle otherErr
}
// handle everything else
}
Comment From: zigo101
Or more generally, it would be good to pass type arguments to any value parameters of type parameter types. It is equivalent to pass zero values of the type arguments.
[Edit] This could unify built-in generic functions and custom ones to some extent.
func new[T any](T) *T
is much better than the illogical fake declaration:
func new(Type) *Type
Comment From: earthboundkid
I don't see much benefit to duplicating the existing API. This also implies a similar change to eg json.Marshal etc. We would end up with duplicate functions throughout the standard library. And as noted this doesn't add any type safety; it is just more convenient, although even that is debatable. I think generics should be reserved for areas where they either add real type safety or big convenience, and cases like this of minor convenience can stay as they are.
Comment From: neild
And as noted this doesn't add any type safety
That's not quite true: This does add type safety at the language level, rather than leaving it to a go vet
check. Under this proposal, this code would not be valid:
type MyError struct{}
func (*MyError) Error() string { return "MyError" }
func main() {
var err error
m, ok := errors.AsA[MyError](err) // MyError does not implement error (Error method has pointer receiver)
fmt.Println(m, ok)
}
The equivalent errors.As
call is a run-time panic or go vet
failure:
second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
Comment From: slnt
A sidenote is the original error inspection draft design includes this blurb:
Here we are assuming the use of the contracts draft design to make errors.As explicitly polymorphic:
func As(type E)(err error) (e E, ok bool)
so its not I guess a new idea
Comment From: GeorgeMac
Love this, I stumbled across this pattern, made a quick blog post about it. Then, of course, that is when I find all the proposals 😂.
I think you can get it down to this terse definition:
func AsA[E error](err error) (e E, _ bool) {
return e, errors.As(err, &e)
}
Comment From: phenpessoa
There are a few benefits of having a generic version of As
, that I don't think have been brought up here.
I mentioned them in: https://github.com/golang/go/issues/56949
User @Jorropo wrote the function this way:
func AsOf[E error](err error) (E, bool) {
var ptrToE *E
for err != nil {
if e, ok := err.(E); ok {
return e, true
}
if x, ok := err.(interface{ As(any) bool }); ok {
if ptrToE == nil {
ptrToE = new(E)
}
if x.As(ptrToE) {
return *ptrToE, true
}
}
err = Unwrap(err)
}
var zero E
return zero, false
}]
Note that it does not use the current As
implementation.
The benefits of this implementation are:
- No usage of reflection
- No runtime panic possibility
- An allocation free path
- Compile time type safety
- Faster
Comment From: joncalhoun
All of these proposals are looking for a way to return the error as a specific type, which I agree is nice if possible, but have there been any discussions around simply improving errors.As
with generics? Specifically:
func As[T error](err error, target *T) bool {
// This is used to show the functionality works the same
return errors.As(err, target)
}
At first glance this appears to be a breaking change, but passing anything that doesn't meet this criteria into the As
function would result in a panic. This change would alert people of the issue sooner (during compile time) rather than at runtime.
It is also very possible I am missing an edge case.
I have looked, but haven't found an issue that discusses this approach. Please let me know if one exists.
Comment From: neild
At first glance this appears to be a breaking change
This would break the following:
var _ = errors.As
Comment From: joncalhoun
This would break the following:
var _ = errors.As
Darn, you are right. I missed that case. Kinda sucks though, because the change would absolutely help with bugs that aren't discovered until testing or some other runtime occurrence.
Comment From: mkielar
64629 was closed as duplicate, so let me advertise my idea here. Perhaps, instead of adding new IsA
function, it would be easier to add something that actually produces that double pointer? Like this:
func AsTarget[T error](err T) *T {
p1 := &err
return p1
}
Then we could:
if errors.As(err, AsTarget(&MyErr{})) {
...
}
Comment From: earthboundkid
You couldn’t use the value, so it seems like it wouldn’t be useful most of the time.
Comment From: mkielar
Oooh, okay, now I get it. I'm new to go, and I didn't RTFM, so that's on me. I missed the fact that error.As
actually sets the target
to the value of the error it finds (if it finds it). My idea still holds when one doesn't need that value (which is rare, probably) and makes little sense in a wider context.
Learned something today, thanks @carlmjohnson.
Comment From: mitar
@mkielar: If you do not need value, you use errors.Is
.
Comment From: neilotoole
Per (closed) duplicate proposal #64771, I advocate for the name errors.Has
. Implementation would look like:
// Has returns true if err, or an error in its error tree, matches error type E.
// An error is considered a match by the rules of [errors.As].
func Has[E error](err error) bool {
return errors.As(err, new(E))
}
Comment From: earthboundkid
A) It's an Is check, not really an As check, which is less useful. B) That would be very prone to accidental misuse in which the type system would infer error
instead of a concrete error type.
Comment From: earthboundkid
err = container.RunExec(ctx, s.dockerCli, target.ID, exec)
if errors.Has[*cli.StatusError](err) {
return sterr.StatusCode, nil
}
return 0, err
sterr.StatusCode
won't work because sterr
is never declared.
Comment From: imax9000
Until this gets into the stdlib, I'm using a simple wrapper around the whole errors
package: https://pkg.go.dev/github.com/imax9000/errors
Comment From: jalaziz
In our codebase, we've noticed the need for two different methods:
* func AsA[E error](err error) (E, bool)
* func IsA[E error](err error) bool
AsA
can generally be used everyone IsA
is used by ignoring the first return value, except for switch statements. The value of IsA
is that it matches on types as compared to errors.Is
which will generally fail unless the error instances are exactly equal (or the error has an Is
method that overrides the default behavior).
I doubt both methods would get included in the standard library, but besides added type safety and convenience, I've found that both methods make for much cleaner and simpler error-checking code.
Comment From: jub0bs
@slnt FWIW, I've just released jub0bs/errutil, a small utility package that exports the following function:
func Find[T error](err error) (T, bool)
Find
has the same signature as your IsA
but it's more efficient, since its implementation is generic all the way down instead of relying on errors.As
.
Comment From: jub0bs
In the wake of https://go.dev/blog/error-syntax, perhaps we should revive
- this proposal, or
- https://github.com/golang/go/issues/56949, or
- https://github.com/golang/go/issues/64771?
A generic function in the spirit of errors.As
goes a long way in terms of ergonomics, type safety, and performance.
Comment From: apparentlymart
Although I agreed with the argument that this didn't seem to add much for type checking or ergonomic usage -- in particular, it's still not possible to write the entire error-projection mess inside the expression of a case
clause in a switch
statement -- the performance argument seems considerably more compelling1.
If performance were the only motivation then we could presumably get that same benefit with a signature more like the current errors.As
:
func GenericAsBikeshed1[T any](err error, target *T) bool
...which would then make it (more or less) a drop-in replacement for the current errors.As
, albeit not strictly compatible enough to be able to avoid adding a new symbol.
Let's compare how these different options look with if
statements:
// Base case: errors.As in its current form
var specialErr *ErrSpecial
if errors.As(err, &specialErr) {
// Do something with specialErr
}
// Generic version of errors.As with a similar signature
var specialErr *ErrSpecial
if errors.GenericAsBikeshed1(err, &specialErr) {
// Do something with specialErr
}
// Generic version with two results instead of writing through a pointer.
if specialErr, ok := errors.GenericAsBikeshed2[*ErrSpecial](err); ok {
// Do something with specialErr
}
This shape keeps the temporary typed-error value scoped to the error-handling block, which is nice.
My brain got a little caught up on the oddity of using a variable named ok
to represent "there is an error" 😬 but I expect I'd get over that before too long, and of course that's a decision each caller can make separately anyway.
Now with switch
statements:
// Base case: errors.As in its current form
var specialErr *ErrSpecial
switch {
case errors.As(err, &specialErr):
// Do something with specialErr
default:
// ...
}
// Generic version of errors.As with a similar signature
var specialErr *ErrSpecial
switch {
case errors.GenericAddBikeshed1(err, &specialErr):
// Do something with specialErr
default:
// ...
}
As others have noted, it doesn't seem like a switch
/case
usage of errors.GenericAddBikeshed2
is possible, because case
doesn't support the simple-statement; predicate
form that's allowed in if
. I think the closest we can get is:
// Generic version with two results instead of writing through a pointer.
var specialErr *ErrSpecial
switch specialErr, ok := errors.GenericAsBikeshed2[*ErrSpecial](err); ok {
case true
// Do something with specialErr
default:
// specialErr is its zero value here
}
...but this is just a very strange way of spelling an if
statement, so not particularly useful.
I don't personally think the lack of working with switch
/case
is necessarily disqualifying, but since the switch
/case
usage of errors.As
is typically used as the modern replacement for a direct type switch over the err
I think folks do still like to be able to write it out that way.
Although this would of course be an entirely separate proposal, I wonder if there's any chance of a future language change to support something like the simple-statement form in the case
clauses of a boolean switch:
switch {
case specialErr, ok := errors.GenericAsBikeshed2[*ErrSpecial](err); ok:
// Do something with specialErr
default:
// specialErr is its zero value here
}
Exactly what I wrote above seems likely to be ambiguous though, so probably it would need different syntax? I'm not sure. (This also reminds me about https://github.com/golang/go/issues/67316, https://github.com/golang/go/issues/67372, and https://github.com/golang/go/issues/70169, which were all more elaborate language changes to support type-switch-like handling of errors.)
If we thought that something like the above might become possible in future then for me that would remove my one reservation about the generic form with two results, rather than the generic form with a pointer. But if we wanted to rely on that here then we'd probably want to discuss it in a separate proposal issue first. 🤔
-
Performance arguments often need to argue that there isn't some way that the compiler's optimizer couldn't get smarter about optimizing the current form too.
It seems like optimizing the dynamic form to be roughly equivalent to the generic form is possible in principle giving a suitably effective inliner and devirtualizer, but that seems like a much bigger lift than a generic form of
errors.As
, and myGenericAsBikeshed1
example still comes with the advantage of statically checking that what's provided intarget
is a pointer, whereas that's a runtime error in the currenterrors.As
. ↩