go: proposal: errors: add Errors function to undo Join

I propose to add errors.Errors (note: I don’t like the name either, please think of it as a placeholder) function to handle errors that contain multiple errors.

Problem

Upon trying out go1.20rc1’s new features around errors.Join, I could not immediately find out how one should access individual errors in sequence:

err := interestingFunc()
for err := range err.Errors() { // obviously does not compile: Errors does not exist
   // do something with err
}

This sort of sequential scan is necessary if the contained error does not implement a distinct type or its own Is(error) bool function, as you cannot extract out plain errors created by errors.New/fmt.Errorf using As(). You also would need to know what kind of errors exists before hand in order to use As, which is not always the case.

It can be argued that users are free to use something like interface{ Unwrap() []error } to convert the type of the error first, and then proceed to extract the container errors:

if me, ok := err.(interface{ Unwrap() []error }); ok {
  for _, err := range me.Unwrap() {
     ...
  }
}

This works, but if the error returned could be either a plain error or an error containing other errors, you would have to write two code paths to handle the error:

switch err := err.(type) {
case interface{ Unwrap() []error }:
   ... // act upon each errors that are contained
default:
   ... // act upon the error itself
}

This forces the user to write much boilerplate code. But if we add a simple function to abstract out the above type conversion, the user experience can be greatly enhanced.

Proposed solution

Implement a thin wrapper function around error types:

package errors

// Errors can be used to against an `error` that contains one or more errors in it (created
// using either `errors.Join` or `fmt.Errorf` with multiple uses of "%w").
//
// If the error implement `Unwrap() []error`, the result of calling `Unwrap` on `err` is returned.
// Only the immediate child errors are returned: this function will not traverse into the child
// errors to find out all of the errors contained in the error.
//
// If the error specified as the argument is a plain error that does not contain other errors,
// the error itself is used as the sole element within the return slice.
func Errors(err error) []error {
   switch err := err.(type) {
   case interface { Unwrap() []error }:
     return err.Unwrap()
   default:
     return []error{err}
   }
}

Pros/Cons

Pros:

  • Cleaner way to access joined errors: users will no longer need to be aware of the difference between a plain error and errors containing multiple child errors
  • Less boilerplate code for something that every developer will need to write when dealing with errors created by errors.Join
  • An explicit function is more discoverable

Cons:

  • There’s a slight asymmetry in the behavior: whereas the error value itself is returned for plain errors, the result for errors containing other errors does not include the original error itself.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 21 (16 by maintainers)

Most upvoted comments

It doesn’t seem like there is consensus that we should add Split. We only just added Join. Perhaps we should wait for more use cases. If third-party users write and use the Split function, it will be easy enough to add later and will not invalidate any of the other copies.

ISTM, this is blocked until #56413 is resolved, then it should just return an iterator. Suggested name errors.BreadthFirst.

Thanks for the detailed explanation @lestrrat.

You suggest that you’d like users to write code like this:

for _, child := range errors.Errors(err) {
  switch child := child.(type) {
  case *mypkg.InterestingErr1:
    // ...
  }
}

This approach only works when err wraps the exact list of errors to examine. If err or one of the errors it contains is wrapped, then this will fail to properly detect the desired contained error.

For example, if we write a function that wraps the result of driverCode such as this

func f1(() error {
  if err := driverCode(); err != nil {
    return fmt.Errorf("additional context: %w", err)
  }
  // ...
}

then a single-level unwrapping of the error returned by f1 will not properly examine the contained errors.

To avoid the need to reason about the structure of the error tree, I would recommend using errors.Is or errors.As to look for the desired error instead, since these functions will recursively examine the entire error tree.

If there is a need to provide a specific list of errors to the user, I’d recommend defining concrete error type that contains that error. For example:

type DriverError {
  Errs []error
}

func (e DriverError) Error() string { return "some error text" }
func (e DriverError) Unwrap() []error { return e.Errs }

Users can extract the DriverError type using either a type assertion or errors.As to get at the list of contained errors.

After my previous comment I have realized that joined errors have much more semantic meaning in the join itself than regular wrap. Regular wrap can be used for many reasons to annotate the original error (with a stack trace, additional data, messages, etc.) and so automatic traversal of that wraps make sense when you are searching for example for which layer of wrapping recorded a stack trace or which type the base error is. When joining errors, semantic if much clearer: those multiple errors happened and we are combining them now here. But reversing/traversing those joined errors automatically have much less sense: if you are searching for a stack trace, which one you want from all those errors? If you are searching for a base type, does it make sense to return if any of the errors have that base type?

So in my errors package I have made it so that unjoining is a very different action than just simple linear unwrapping.

Can you give a concrete example of where you needed this function?

Split was dropped from #53435 because we couldn’t come up with sufficient examples for when it might be needed. Specific examples of when it would be useful would be informative.