'Applying `errors.Is` and `errors.As` on custom made struct errors

package main

import (
    "errors"
    "fmt"
)

type myError struct{ err error }

func (e myError) Error() string { return e.err.Error() }

func new(msg string, args ...any) error {
    return myError{fmt.Errorf(msg, args...)}
}

func (e myError) Unwrap() error        { return e.err }
func (e myError) As(target any) bool   { return errors.As(e.err, target) }
func (e myError) Is(target error) bool { return errors.Is(e.err, target) }

func isMyError(err error) bool {
    target := new("")
    return errors.Is(err, target)
}

func asMyError(err error) (error, bool) {
    var target myError
    ok := errors.As(err, &target)
    return target, ok
}

func main() {
    err := fmt.Errorf("wrap: %w", new("I am a myError"))

    fmt.Println(isMyError(err))
    fmt.Println(asMyError(err))

    err = fmt.Errorf("wrap: %w", errors.New("I am not a myError"))

    fmt.Println(isMyError(err))
    fmt.Println(asMyError(err))
}

I expected

true
I am a myError true
false
I am not a myError false

but I got

false
I am a myError true
false
%!v(PANIC=Error method: runtime error: invalid memory address or nil pointer dereference) false

I tried to add

func (e myError) Unwrap() error        { return e.err }
func (e myError) As(target any) bool   { return errors.As(e.err, target) }
func (e myError) Is(target error) bool { return errors.Is(e.err, target) }

I tried

func asMyError(err error) (error, bool) {
    target := &myError{} // was 'var target myError' before
    ok := errors.As(err, &target)
    return target, ok
}

I tried

func new(msg string, args ...any) error {
    return &myError{fmt.Errorf(msg, args...)} // The change is the character '&'
}

but none of these changed anything. I also tried

func asMyError(err error) (error, bool) {
    target := new("") // // was 'var target myError' or 'target := &myError{}' before
    ok := errors.As(err, &target)
    return target, ok
}

and then I got

false
wrap: I am a myError true
false
wrap: I am not a myError true

, which I guess makes sense but again does not solve my problem. I have a hard time to wrap my head this problem. Can you give me a hand?



Solution 1:[1]

So the point of errors.Is and errors.As is that errors can be wrapped, and these functions allow you to extract what the underlying cause of a given error was. This essentially relies on certain errors having specific error values. Take this as an example:

const (
    ConnectionFailed   = "connection error"
    ConnectionTimedOut = "connection timed out"
)

type PkgErr struct {
    Msg string
}

func (p PkgErr) Error() string {
    return p.Msg
}

func getError(msg string) error {
    return PkgErr{
        Msg: msg,
    }
}

func DoStuff() (bool, error) {
    // stuff
    err := getError(ConnectionFailed)
    return false, fmt.Errorf("unable to do stuff: %w", err)
}

Then, in the caller, you can do something like this:

_, err := pkg.DoStuff()
var pErr pkg.PkgErr
if errors.As(err, &pErr) {
    fmt.Printf("DoStuff failed with error %s, underlying error is: %s\n", err, pErr)
}

Or, if you only want to handle connection timeouts, but connection errors should instantly fail, you could do something like this:

accept := pkg.PkgErr{
    Msg: pkg.ConnectionTimedOut,
}
if err := pkg.DoStuff(); err != nil {
    if !errors.Is(err, accept) {
        panic("Fatal: " + err.Error())
    }
    // handle timeout
}

There is, essentially, nothing you need to implement for the unwrap/is/as part. The idea is that you get a "generic" error back, and you want to unwrap the underlying error values that you know about, and you can/want to handle. If anything, at this point, the custom error type is more of a nuisance than an added value. The common way of using this wrapping/errors.Is thing is by just having your errors as variables:

var (
    ErrConnectionFailed   = errors.New("connection error")
    ErrConnectionTimedOut = errors.New("connection timed out")
)
// then return something like this:
return fmt.Errorf("failed to do X: %w", ErrConnectionFailed)

Then in the caller, you can determine why something went wrong by doing:

if error.Is(err, pkg.ErrConnectionFailed) {
    panic("connection is borked")
} else if error.Is(err, pkg.ErrConnectionTimedOut) {
    // handle connection time-out, perhaps retry...
}

An example of how this is used can be found in the SQL packages. The driver package has an error variable defined like driver.ErrBadCon, but errors from DB connections can come from various places, so when interacting with a resource like this, you can quickly work out what went wrong by doing something like:

 if err := foo.DoStuff(); err != nil {
    if errors.Is(err, driver.ErrBadCon) {
        panic("bad connection")
    }
 }

I myself haven't really used the errors.As all that much. IMO, it feels a bit wrong to return an error, and pass it further up the call stack to be handled depending on what the error exactly is, or even: extract an underlying error (often removing data), to pass it back up. I suppose it could be used in cases where error messages could contain sensitive information you don't want to send back to a client or something:

// dealing with credentials:
var ErrInvalidData = errors.New("data invalid")

type SanitizedErr struct {
    e error
}

func (s SanitizedErr) Error() string { return s.e.Error() }

func Authenticate(user, pass string) error {
    // do stuff
    if !valid {
        return fmt.Errorf("user %s, pass %s invalid: %w", user, pass, SanitizedErr{
            e: ErrInvalidData,
        })
    }
}

Now, if this function returns an error, to prevent the user/pass data to be logged or sent back in any way shape or form, you can extract the generic error message by doing this:

var sanitized pkg.SanitizedErr
_ = errors.As(err, &sanitized)
// return error
return sanitized

All in all though, this has been a part of the language for quite some time, and I've not seen it used all that much. If you want your custom error types to implement an Unwrap function of sorts, though, the way to do this is really quite easy. Taking this sanitized error type as an example:

func (s SanitizedErr) Unwrap() error {
    return s.e
}

That's all. The thing to keep in mind is that, at first glance, the Is and As functions work recursively, so the more custom types that you use that implement this Unwrap function, the longer the actual unwrapping will take. That's not even accounting for situations where you might end up with something like this:

boom := SanitizedErr{}
boom.e = boom

Now the Unwrap method will simply return the same error over and over again, which is just a recipe for disaster. The value you get from this is, IMO, quite minimal anyway.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1