Skip to content

Commit

Permalink
Support Go 1.13 errors.As/Is/Unwrap functionality
Browse files Browse the repository at this point in the history
The primary mechanism that enables this functionality is making `Unwrap`
on the top-level Error return a new "chain" structure that uses state to
keep track of the current error.

The chain implements errors.Is/As so that it compares to that current
underlying error. And it implements Unwrap to move on to the next error.

A well-formed program using errors.Is/As/Unwrap exclusively will behave
correctly with go-multierror in this case without dropping any errors.
Direct comparisons such as `Unwrap() == myErr` will not work because we
wrap in a chain. The user has to do `errors.Is(err, myErr)` which is the
right thing to do anyways.

When Unwrap is called on a top-level *Error, we create a shallow copy of
the errors so that you can continue using *Error (modifying it,
potentially in-place). There is a slight cost to this but it felt weird
that calling Unwrap would share the same underlying data and cause
potential data races. I think this is unlikely, but its also very
unlikely the performance cost of the shallow copy of errors will matter
either.

Thanks to @felixge on Twitter for pushing me to do this, showing some
examples on how it could be done, and providing feedback to this PR.
  • Loading branch information
mitchellh committed Mar 31, 2020
1 parent ece20dc commit 58e4f18
Show file tree
Hide file tree
Showing 2 changed files with 172 additions and 0 deletions.
69 changes: 69 additions & 0 deletions multierror.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package multierror

import (
"errors"
"fmt"
)

Expand Down Expand Up @@ -49,3 +50,71 @@ func (e *Error) GoString() string {
func (e *Error) WrappedErrors() []error {
return e.Errors
}

// Unwrap returns an error from Error (or nil if there are no errors).
// This error returned will further support Unwrap to get the next error,
// etc. The order will match the order of Errors in the multierror.Error
// at the time of calling.
//
// The resulting error supports errors.As/Is/Unwrap so you can continue
// to use the stdlib errors package to introspect further.
//
// This will perform a shallow copy of the errors slice. Any errors appended
// to this error after calling Unwrap will not be available until a new
// Unwrap is called on the multierror.Error.
func (e *Error) Unwrap() error {
// If we have no errors then we do nothing
if e == nil || len(e.Errors) == 0 {
return nil
}

// Shallow copy the slice
errs := make([]error, len(e.Errors))
copy(errs, e.Errors)

return &chain{errors: errs}
}

// chain implements the interfaces necessary for errors.Is/As/Unwrap to
// work in a deterministic way with multierror. A chain tracks a list of
// errors while accounting for the current represented error. This lets
// Is/As be meaningful.
//
// Unwrap returns the next error. In the cleanest form, Unwrap would return
// the wrapped error here but we can't do that if we want to properly
// get access to all the errors. Instead, users are recommended to use
// Is/As to get the correct error type out.
type chain struct {
idx int
errors []error
}

// Error implements the error interface
func (e *chain) Error() string {
return e.current().Error()
}

// Unwrap implements errors.Unwrap by returning the next error in the
// chain or nil if there are no more errors.
func (e *chain) Unwrap() error {
next := e.idx + 1
if len(e.errors) <= next {
return nil
}

return &chain{idx: next, errors: e.errors}
}

// As implements errors.As by attempting to map to the current value.
func (e *chain) As(target interface{}) bool {
return errors.As(e.current(), target)
}

// Is implements errors.Is by comparing the current value directly.
func (e *chain) Is(target error) bool {
return e.current() == target
}

func (e *chain) current() error {
return e.errors[e.idx]
}
103 changes: 103 additions & 0 deletions multierror_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,106 @@ func TestErrorWrappedErrors(t *testing.T) {
t.Fatalf("bad: %s", multi.WrappedErrors())
}
}

func TestErrorUnwrap(t *testing.T) {
t.Run("with errors", func(t *testing.T) {
err := &Error{Errors: []error{
errors.New("foo"),
errors.New("bar"),
errors.New("baz"),
}}

var current error = err
for i := 0; i < len(err.Errors); i++ {
current = errors.Unwrap(current)
if !errors.Is(current, err.Errors[i]) {
t.Fatal("should be next value")
}
}

if errors.Unwrap(current) != nil {
t.Fatal("should be nil at the end")
}
})

t.Run("with no errors", func(t *testing.T) {
err := &Error{Errors: nil}
if errors.Unwrap(err) != nil {
t.Fatal("should be nil")
}
})

t.Run("with nil multierror", func(t *testing.T) {
var err *Error
if errors.Unwrap(err) != nil {
t.Fatal("should be nil")
}
})
}

func TestErrorIs(t *testing.T) {
errBar := errors.New("bar")

t.Run("with errBar", func(t *testing.T) {
err := &Error{Errors: []error{
errors.New("foo"),
errBar,
errors.New("baz"),
}}

if !errors.Is(err, errBar) {
t.Fatal("should be true")
}
})

t.Run("without errBar", func(t *testing.T) {
err := &Error{Errors: []error{
errors.New("foo"),
errors.New("baz"),
}}

if errors.Is(err, errBar) {
t.Fatal("should be false")
}
})
}

func TestErrorAs(t *testing.T) {
match := &nestedError{}

t.Run("with the value", func(t *testing.T) {
err := &Error{Errors: []error{
errors.New("foo"),
match,
errors.New("baz"),
}}

var target *nestedError
if !errors.As(err, &target) {
t.Fatal("should be true")
}
if target == nil {
t.Fatal("target should not be nil")
}
})

t.Run("without the value", func(t *testing.T) {
err := &Error{Errors: []error{
errors.New("foo"),
errors.New("baz"),
}}

var target *nestedError
if errors.As(err, &target) {
t.Fatal("should be false")
}
if target != nil {
t.Fatal("target should be nil")
}
})
}

// nestedError implements error and is used for tests.
type nestedError struct{}

func (*nestedError) Error() string { return "" }

0 comments on commit 58e4f18

Please sign in to comment.