Error Groups¶
ErrorGroup aggregates multiple errors into a single error-implementing value. Use it for validation passes, batch operations, fan-out fan-in goroutine work, or anywhere you'd otherwise return the first error and silently drop the rest.
Quick start¶
eg := ewrap.NewErrorGroup()
eg.Add(validate(req))
eg.Add(persist(req))
eg.Add(notify(req))
if err := eg.ErrorOrNil(); err != nil {
return err
}
Add(nil) is a no-op, so you can call it unconditionally.
Pooled allocation¶
For high-throughput paths, reuse ErrorGroup instances via ErrorGroupPool:
pool := ewrap.NewErrorGroupPool(4) // initial slice capacity
eg := pool.Get()
defer eg.Release() // returns it to the pool, cleared
eg.Add(err1)
eg.Add(err2)
Release() clears the underlying slice (preserving capacity) and puts the group back in the pool. Calling Release() on a non-pooled group is a no-op.
Reading the group¶
eg.HasErrors() // bool
len(eg.Errors()) // count (clones the slice)
eg.Error() // formatted "N errors occurred:\n..." text
eg.ErrorOrNil() // returns eg if non-empty, else nil
eg.Join() // errors.Join semantics — single, multi-cause error
Errors() returns a defensive copy via slices.Clone so callers can't mutate the group's internal state.
errors.Is / errors.As over a group¶
Join() returns a value compatible with errors.Join, so the stdlib walks through every member:
joined := eg.Join()
errors.Is(joined, sql.ErrNoRows) // true if any member matches
errors.Is(joined, io.EOF) // ditto
errors.As(joined, &myCustomError) // first matching member fills target
Concurrent use¶
Add, HasErrors, Error, Errors, Join, and Clear are all goroutine-safe via an internal sync.RWMutex. Typical fan-out fan-in:
eg := pool.Get()
defer eg.Release()
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
eg.Add(process(it))
}(item)
}
wg.Wait()
if err := eg.Join(); err != nil {
return err
}
Serialization¶
ErrorGroup implements json.Marshaler and yaml.Marshaler, plus explicit ToJSON() / ToYAML() methods for callers that want the result as a string.
The serialized payload includes:
{
"error_count": 2,
"timestamp": "2026-05-02T10:11:12Z",
"errors": [
{
"message": "validation failed: missing field 'email'",
"type": "ewrap",
"stack_trace": [
{"function": "...", "file": "...", "line": 42, "pc": 12345}
],
"metadata": {"field": "email"},
"cause": null
},
{
"message": "EOF",
"type": "standard"
}
]
}
The cause chain is preserved for both *Error members and standard wrapped errors (the serializer walks them via errors.Unwrap), so transport consumers see the full picture.
Patterns¶
Validation pass¶
func validateOrder(o Order) error {
eg := pool.Get()
defer eg.Release()
if o.Customer == "" {
eg.Add(ewrap.New("missing customer",
ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)))
}
if o.Total <= 0 {
eg.Add(ewrap.New("invalid total",
ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)))
}
return eg.ErrorOrNil()
}
Best-effort cleanup¶
func close(resources []io.Closer) error {
eg := pool.Get()
defer eg.Release()
for _, r := range resources {
eg.Add(r.Close())
}
return eg.Join() // single error containing every Close failure
}
Mixed wrapping¶
eg.Add(ewrap.Wrap(httpErr, "fetching user",
ewrap.WithHTTPStatus(http.StatusBadGateway)))
eg.Add(ewrap.Wrap(dbErr, "loading order"))
eg.Add(io.EOF) // raw stdlib error mixes fine
The serializer normalises all members into the same shape, so consumers don't need to special-case ewrap vs standard errors.
Performance¶
| Operation | ns/op | allocs |
|---|---|---|
Add (non-nil) | ~30 | 0 (steady state) |
Get from pool | ~50 | 0 (warm pool) |
Error() (formatted) | varies | 1 (builder) |
ToJSON (10 entries) | ~10 µs | ~30 |
The pool eliminates the per-error allocation of the slice header in hot paths.