iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

errors.Is and errors.As are not (merely) comparison functions

に公開

https://zenn.dev/kskumgk63/articles/550dc9d42078d968beac

I came across the article above, but it left me with a bit of a "???" impression, so I'm going to rewrite it in my own way.

Error Handling Tactics in Go

Error handling tactics in Go generally fall into one of the following three categories, or a combination thereof:

  1. Checking the equality[1] of error instances (including pointer values).
  2. Extracting a specific type from an error instance.
  3. Interpreting the string output by the error.Error() method.

Well, since the third option is bad know-how, let's gracefully ignore it. The first option corresponds to the errors.Is() function, and the second corresponds to the errors.As() function.

Once Upon a Time...

What did we do before the errors.Is() and errors.As() functions existed?

For example, suppose there was a function like the one below that simply tries to open a file.

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()
    return nil
}

The return value of this function could be evaluated as follows:

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        switch e := err.(type) {
        case *os.PathError:
            if errno, ok := e.Err.(syscall.Errno); ok {
                switch errno {
                case syscall.ENOENT:
                    fmt.Fprintf(os.Stderr, "%v file does not exist\n", e.Path)
                default:
                    fmt.Fprintln(os.Stderr, "Errno =", errno)
                }
            } else {
                fmt.Fprintln(os.Stderr, "Other PathError")
            }
        default:
            fmt.Fprintln(os.Stderr, "Other error")
        }
        return
    }
    fmt.Println("Normal completion")
}

First, the code extracts the *os.PathError type from the returned error instance, and then further extracts its Err attribute as a syscall.Errno type. After that, it determines the error by comparing the syscall.Errno value with predefined instances.

Performing error handling in Go this way requires knowing the internal structure of the error beforehand, which inevitably makes it cumbersome.

Revised Error Handling

Using the errors.Is() and errors.As() functions, the above evaluation can be rewritten like this:

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        var errPath *os.PathError
        if errors.As(err, &errPath) {
            switch {
            case errors.Is(errPath.Err, syscall.ENOENT):
                fmt.Fprintf(os.Stderr, "%v file does not exist\n", errPath.Path)
            default:
                fmt.Fprintln(os.Stderr, "Other PathError")
            }
        } else {
            fmt.Fprintln(os.Stderr, "Other error")
        }
        return
    }
    fmt.Println("Normal completion")
}

Furthermore, if you only need to compare the syscall.Errno value with a predefined instance, you can simply do this:

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        switch {
        case errors.Is(err, syscall.ENOENT):
            fmt.Fprintln(os.Stderr, "File does not exist")
        default:
            fmt.Fprintln(os.Stderr, "Other error")
        }
        return
    }
    fmt.Println("Normal completion")
}

Easy!

Structuring errors vertically with the Unwrap() method

Starting from Go 1.13, error handling takes into account the presence of the Unwrap() method.

errors/wrap.go
// 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.
func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

This makes it possible to handle vertically structured errors using only standard packages. For example, the *os.PathError type is defined as follows:

func (e *PathError) Unwrap() error { return e.Err }

It returns the underlying cause of the error via the Unwrap() method.

As a result, you can bypass the internal structure and directly evaluate the root cause like this:

if errors.Is(err, syscall.ENOENT) {
    ...
}

errors.As() is Embarrassing but Useful

The errors.Is() function is one thing, but the errors.As() function is a bit... well, quite awkward.

var errPath *os.PathError
if errors.As(err, &errPath) {
    ...
}

Normally, the converted type should be returned as a result, but it is passed as a pointer argument. Is this C? (laughs)

In fact, in the original proposal, the errors.As() function was tied to the implementation of generics. For example, like this:

func As(type E)(err error) (e E, ok bool) {
    for {
        if e, ok := err.(E); ok {
            return e, true
        }
        err = Unwrap(err)
        if err == nil {
            return nil, false
        }
    }
}

However, since generics wouldn't be introduced for a while, implementing it within the current specification resulted in that somewhat clunky style. Still, it's convenient to be able to extract a specific type without worrying about the internal structure, so let's make do with the current state while looking forward to the arrival of generics.

So, a Little Promotion

I have extracted the error handling used in my own packages and published it as an independent package. Feel free to use it as is or copy and adapt it to your needs.

https://text.baldanders.info/release/errs-package-for-golang/

Other References

脚注
  1. Using terms like "equivalence" or "identity" is bound to cause confusion, and I have no desire to get dragged into that kind of religious debate. Therefore, I've used the term "equality" simply to mean that the instance value or pointer value is the same. ↩︎

GitHubで編集を提案

Discussion