go: proposal: errors: add Errors as a standard way to represent multiple errors as a single error

Description

It is common practice in golang to return an error from a func in the event of a problem during its execution (e.g. func myFunc() error { ... }).

However, there are a few cases, such as field validation on a struct, where it is useful to return multiple errors from a function (e.g. func validate(m *Model) []error). Sometimes these errors need to be ‘bubbled up’ through a call stack that only returns one error. So, I find myself having to re-implement custom errors that represent many errors in each of my projects.

I think a generic solution could be added to the standard libraries’ errors package that serves this purpose.

Example Implementation

I’ve updated the example implementation to incorporate the feedback from @neild and @D1CED

package util

import (
	"fmt"
)

// Errors are many errors that can be represented as a single error
type Errors interface {
	Errors() []error
	error
}

// NewErrors combine many errors into a single error
func NewErrors(errs ...error) error {
	if lenErrs := len(errs); lenErrs == 0 {
		return nil
	} else if lenErrs == 1 {
		return errs[0]
	}
	var es manyErrors
	for _, err := range errs {
		// Merge many slices of errors into a single slice
		if errs, ok := err.(Errors); ok {
			es = append(es, errs.Errors()...)
			continue
		}
		es = append(es, err)
	}
	return es
}

// manyErrors combines many errors into a single error
type manyErrors []error

// Is works with errors.Is
func (es errors) Is(find error) bool {
        for _, e := range es {
                if Is(e, find) {
                        return true
                }
        }
        return false
}       

// As works with errors.As
func (es errors) As(find interface{}) bool {
        for _, e := range es {
                if As(e, find) {
                        return true
                }
        }
        return false
}

// Errors implements the Errors interface
func (es manyErrors) Errors() []error {
	return []error(es)
}

// Error implements the error interface
func (es manyErrors) Error() string {
	return fmt.Sprintf("[%v", []error(es))
}

Example Usage

import "errors"

type Model struct {
	FieldOne   string `json:"field_one"`
	FieldTwo   string `json:"field_two"`
	FieldThree string `json:"field_three"`
}

func (m *Model) Validate() error {
	var errs []error
	if len(m.FieldOne) < 1 {
		errs = append(errs, errors.New("'field_one' is required"))
	}
	if len(m.FieldTwo) < 1 {
		errs = append(errs, errors.New("'field_two' is required"))
	}
	if len(m.FieldThree) < 1 {
		errs = append(errs, errors.New("'field_three' is required"))
	}
	return errors.NewErrors(errs...)
}

Conclusion

Whether or not this proposal is accepted, I’m curious how others have approached this problem in the past and if there’s already a commonly referenced solution that I’m unfamiliar with. Thanks 🙏

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 5
  • Comments: 45 (24 by maintainers)

Most upvoted comments

If err contains multiple errors, what does errors.Is(err, fs.ErrExist) return?

  • true if all errors in err are fs.ErrExist?
  • true if at least one error in err is fs.ErrExist?
  • always false?

If we use errors.As to convert a multierror value to a *os.PathError, does it:

  • return true if at least one error in err is an *os.PathError, and extract one of the *os.PathErrors?
  • return true if the only error in err is an *os.PathError?
  • always return false?

After errors.Join(";", err1, err2), the result would print like this: fmt.Sprintf(“%s; %s”, err1, err2).

But the nice thing about the proposal is that you don’t have to use errors.Join if you don’t want to. The important part is the Unwraps method and function and the changes to Is and As, allowing multierrors of all kinds to behave the same.

I’m still of the opinion that just about every place I’ve seen a multierror used would have been better served by an explicit []error. However, this is a common request and there are a number of competing packages providing multierror implementations. If there’s enough commonality between those implementations, that may argue for standardizing on a common interface.

My own modest proposal:

An error wraps multiple errors if its type has the method

Unwrap() []error

Reusing the name Unwrap is proposed by @jimmyfrasche above, and has the nice characteristic of avoiding ambiguity when an error has singular and multiple unwraps. It also avoids changing behavior, since I don’t think any existing multierror implementations implement this method signature. Returning a 0-length list from Unwrap means the error doesn’t wrap anything.

The errors.Split function provides a mechanism for retrieving the original errors:

// Split returns the result of calling the Unwrap method on err,
// if err's type contains an Unwrap method returning []error.
// Otherwise, Split returns nil.
func Split(err error) []error

The errors.Join function provides a simple implementation of a multierr. It does not flatten errors.

// Join returns an error that wraps the given errors.
// The error formats as the text of the given errors, separated by sep.
// Join returns nil if len(errs) is 0.
func Join(sep string, errs ...error) error

The errors.Is and errors.As functions are updated to unwrap multiple errors. We replace the term “error chain” with “error tree”. Is reports a match if any error in the tree matches. As finds the first error in a preorder traversal of the tree that matches.

This proposal:

  • Codifies a representation of a tree of errors.
  • Places the responsibility for tree traversal in errors.Is/errors.As rather than leaving it up to multierror implementations to implement their own Is/As methods. This standardizes the tree traversal, and follows the model used for wrapping singular errors.
  • Leaves the question of formatting multiple errors largely up to the user.
  • Allows for third-party implementations that flatten errors, provide alternate formatting, etc.

fmt.Errorf should be updated to take multiple %w and when there’s more than one return a type with Unwrap() []error

I’m sorry I missed this discussion, although at the time I would have probably agreed with the decision. Now I think there are clear answers to the questions posed here. Namely:

  • the Multierror type does not implement Unwrap (this is the key new idea)
  • errors.Is reports true for a MultiError if it is true for any of the contained errors
  • errors.As does the same; it traverses the contained errors left to right
  • An Errors method returns the flattened list of errors, where the flattening only occurs if the contained error is itself a Multierror. Since a Multierror is nothing but a collection of errors, no information is lost.

This matches Tailscale’s design.

Are any of these controversial? It seems to me that once you get rid of Unwrap, these are all more or less obvious. I was always hung up on figuring out what Unwrap would mean, on the assumption that it would be used a lot. (People used to talk about errors “causes” all the time, meaning the last error in the chain.) My understanding now is that we were successful in promoting Is and As, so that it’s rare to call Unwrap. If that’s correct, then dropping it here will cause little pain.

Formatting is still an open question (here is one cute idea).

To be clear, I’m not asking to reopen this proposal yet, nor yet asking for formatting ideas. I’m conducting a poll: are the semantics I presented wrong for anyone? Because if not, then the main reason for rejection–there are too many ways to do this—is in question.

Filed #52936 as a different way to think about printing multierrs.

@neild, one problem with Unwrap() []error is that the implementor must either copy, or risk exposing their internal state to mischief. The latter is probably the right choice but I think we should add “don’t modify the slice” to the method doc.

The discrepancy between the Unwrap method and the Split function is unfortunate. We could call them both Unwraps.

Join should elide nil errors, returning nil if they’re all nil.

To be precise, the traversal of As should be specified as preorder left-to-right.

@maja42, I think you could use this to simplify your implementation. By removing Unwrap, you also can get rid of the allocation and copying you need in order to make a chain.

Interesting. I think one of the keys has to be that multierror(multierror) gets flattened but multierror(wrapper(multierror)) does not, so that a wrapper is never lost in the process of creating the multierror. Once you do that, one of the main objections I had to this proposal goes away.

I would just like to say to everyone involved that I really appreciate the lively discussion and consideration of this proposal. It uncovered a number of issues, use cases and concerns regarding the unwrapping and equality of errors that I was previously unaware of. And now I have a much deeper understanding of how error wrapping is being used in practice and all of the ways gophers are interested in potentially using a multierror.

I still think there’s an opportunity to bring a more integrated, stripped back, proposal of an Errors interface to the standard library - one that side steps many of these concerns, but is perhaps less useful on its own. I’ll be sure to link that proposal to this one once it’s ready.

Again, a big thank you to everyone involved. This was, if nothing else, enlightening 🙏

I used hashicorp for a while, but ran into the same problems as you already discovered:

What happens when appending/merging/flattening errors that are multi-errors? How to format errors? What about As/Is?

A lot of those questions need to be decided on a case-by-case basis (even within the same project), so in the end I wrote my own multierr implementation which I now use for all of my private- and work projects. It covers all those situations gracefully. Only the As/Is/Unwrap behavior can be controversial, which is why I implement Inspect().

Usually I prefer the Append() function, which does not flatten the tree. I only use Merge() (=flatten errors) when writing validation functions on smaller, nested structs.

So yes, this is a pretty common issue. But there’s no universal solution, and already a few libraries. So I don’t think this should be added to the standard library.

Suppose you have error 1 which is a normal error and error 2 which fmt.Errorf("my prefix: %w", Multierror{err3, err4}). What happens if you combine error 1 and 2? Does it flatten the tree? If it doesn’t flatten the list, you get unexpected results when you try to iterate through all suberrors. But if it does flatten the list, you lose “my prefix” which might be important.

I have a multierror package: https://github.com/carlmjohnson/errutil. Mine is better than other people’s because it is compatible with both Hashicorp and Uber multierrors. 😃 More seriously, I think the design space here is a little too ambiguous to fit into the standard library. There isn’t one right way to handle unwrapping.

Which way would you prefer @neild, @D1CED?

I don’t know. Any multiple-error type needs to come up with good answers for these questions, however.

Another question without an obviously correct universal answer is what the error string for a collection of errors should look like: Concatenate all the error strings with some separator? One line or multiple lines? Summarize in some fashion, such as deduplicating multiple instances of the same error text?

The fact that there didn’t seem to be an obviously correct universal answer to these questions was a primary reason we didn’t introduce a standard multierror type in Go 1.13. There are now a number of multiple-error types implemented outside the standard library; perhaps considering the experience of these implementations would point at some answers.

One small feature I found when looking at other implementations is that if there is exactly one error it is simply returned unwrapped. This should be considered here too.