- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a config validator written by someone who left the team
two years ago. It checks twelve fields. It returns on the first one
that fails. The user fixes that field, runs again, hits the next
failure, fixes that, runs again. Twelve round trips to validate one
file. Somewhere in the same package there's a type multiError with a hand-written
[]errorError() method that loops and joins
strings with newlines, plus a helper that "adds" errors to a slice,
plus a func (m multiError) ErrorOrNil() error that returns nil
when the slice is empty. Every Go codebase past a certain age has
this type. Most of them wrote it after errors.Join already existed.
errors.Join shipped in Go 1.20, in February 2023. It collapses a
list of errors into one, plays correctly with errors.Is and
errors.As, and unwraps to the underlying slice. You almost never
need the hand-rolled version anymore. Here's the whole feature, and
the one place it still falls short.
What errors.Join actually does
errors.Join takes a variadic list of errors and returns a single
error. Any nil arguments are dropped. If every argument is nil,
the result is nil.
err1 := errors.New("first")
err2 := errors.New("second")
joined := errors.Join(err1, err2)
fmt.Println(joined)
The Error() string is each error's message on its own line:
first
second
That output is fixed by the stdlib: newline-separated, in argument
order. If you want a different separator you still need your own
type, but for the common case of "collect what went wrong and show
it," this is the whole job.
The nil handling is the part people miss. You can pass results
straight in without guarding each one:
func validate(c Config) error {
return errors.Join(
checkName(c),
checkPort(c),
checkTimeout(c),
)
}
If all three checks return nil, validate returns nil. If two of
them fail, you get both messages back in one error, in one pass. No
slice, no ErrorOrNil, no append helper. That func validate
above is the replacement for most of the hand-rolled multierror
types out there.
Unwrapping a joined error
A joined error stores its parts. The standard library exposes them
through an Unwrap() []error method — note the slice, not the
single error that fmt.Errorf("%w") produces. The two unwrap
shapes are different on purpose.
You don't call Unwrap directly most of the time. But when you
need the individual errors back, the type assertion is right there:
joined := errors.Join(err1, err2)
if u, ok := joined.(interface{ Unwrap() []error }); ok {
for _, e := range u.Unwrap() {
fmt.Println("part:", e)
}
}
This matters because the tree of errors can be deeper than one
level. A joined error can contain another joined error, which
contains a wrapped error. The traversal functions in the errors
package walk that whole tree for you, which is the next section.
errors.Is and errors.As across a join
This is the reason to stop hand-rolling. errors.Is and
errors.As understand the multi-error tree. They check every branch.
Start with sentinels:
var (
ErrTooShort = errors.New("name too short")
ErrBadPort = errors.New("port out of range")
)
func validate(c Config) error {
var errs []error
if len(c.Name) < 3 {
errs = append(errs, ErrTooShort)
}
if c.Port < 1 || c.Port > 65535 {
errs = append(errs, ErrBadPort)
}
return errors.Join(errs...)
}
errors.Join accepts a slice spread with ..., so building the
list conditionally and joining once at the end is a clean pattern.
Now the caller can ask about any specific failure inside the join:
err := validate(c)
if errors.Is(err, ErrBadPort) {
// true if ErrBadPort is anywhere in the join
}
if errors.Is(err, ErrTooShort) {
// independently true if that one is present
}
Both checks work on the same combined error. errors.Is walks the
slice returned by Unwrap() []error and returns true if any branch
matches. You get per-failure branching off one aggregated value.
errors.As works the same way for typed errors. Define a struct
error, join several of them, and pull the first match back out:
type FieldError struct {
Field string
Msg string
}
func (e *FieldError) Error() string {
return e.Field + ": " + e.Msg
}
err := errors.Join(
&FieldError{Field: "name", Msg: "too short"},
&FieldError{Field: "port", Msg: "out of range"},
)
var fe *FieldError
if errors.As(err, &fe) {
fmt.Println("first field error:", fe.Field) // name
}
errors.As stops at the first match in tree order, so you get the
name field error here. If you need every FieldError, As is the
wrong tool — walk the tree yourself with the Unwrap() []error
assertion from the previous section, or collect typed errors as you
build the slice so you still have them.
Wrapping a join with context
You can wrap the joined error with fmt.Errorf and the matching
still works through both layers:
err := validate(c)
if err != nil {
return fmt.Errorf("config %q: %w", c.Path, err)
}
Now the error message reads config "app.yaml": followed by the
joined lines. And errors.Is(wrapped, ErrBadPort) still returns
true, because the traversal goes through the fmt.Errorf single
%w unwrap into the join's slice unwrap. Mixed trees are fine. The
errors package walks Unwrap() error and Unwrap() []error
nodes in the same pass.
One caution: %w in fmt.Errorf can take more than one error since
Go 1.20 as well. fmt.Errorf("a: %w, b: %w", e1, e2) builds a
multi-error too. It's handy when you want a custom message format
around the parts instead of plain newlines. Use errors.Join when
you just want the list, use multi-%w when you want a sentence.
When to aggregate vs fail fast
Joining is the right default for one job: validation, where you want
to report everything wrong at once so the user fixes it in a single
edit. Form input, config files, API request bodies, batch records.
The whole value is the complete list.
Fail fast is the right default everywhere the steps depend on each
other. If step two reads a connection that step one opens, there is
no point collecting an error from step two — step one already failed
and step two can't run. Returning on the first error keeps the
control flow honest and avoids running code against state that isn't
there.
A useful test: can the operations run independently and do you want
all their outcomes? Aggregate. Does each step assume the previous
one succeeded? Fail fast. Most request handlers are fail-fast with a
validation block at the top that aggregates. That top block is the
natural home for errors.Join.
There's a performance angle too, though it rarely decides anything.
Aggregating means running every check even after one fails, which
costs more work for a request that's already doomed. For cheap
in-memory validation that cost is noise. For checks that hit a
database or a network, fail fast both saves the round trips and
avoids reporting cascading failures that are really one root cause
wearing several hats.
What to do with this on Monday
Grep your codebase for these and replace them:
-
type multiErrorortype multierroror any[]errorwith a hand-writtenError()method. If all it does is newline-join,errors.Joinreplaces the whole type. -
ErrorOrNilhelpers. That'serrors.Join's nil handling, reimplemented. The join already returns nil when every part is nil. - Validators that
return erron the first failed field. If the fields are independent, collect into a slice anderrors.Joinonce at the end so the caller sees every problem in one pass. -
==comparisons against sentinels anywhere near a function that might return a joined error. Switch toerrors.Is, which walks the whole tree.
The hand-rolled multierror type was the correct call before
February 2023. It hasn't been since. Delete it, and let errors.Is
and errors.As do the tree-walking they were extended to do.
If this was useful
The error-handling chapter is one of the parts of Go that quietly
got better between releases — errors.Join, multi-%w, the
Unwrap() []error contract — and it's easy to miss because the old
patterns keep working. The Complete Guide to Go Programming covers
the error model end to end, including how Is and As traverse the
tree and when a custom error type still earns its place. Hexagonal
Architecture in Go shows where aggregated validation belongs in a
service so the domain layer doesn't drown in framework errors.

Top comments (0)