Documentation
¶
Overview ¶
Package serrors provides structured error handling with customizable formatting and error chaining.
This package supports two modes of operation:
- Package-level functions that operate on a default domain (suitable for applications and simple use cases).
- Domain instances that allow application domains and library authors to create isolated error namespaces with their own formatters.
Overview ¶
This package implements a flexible error system that supports:
- Structured error information with operation context
- Custom error formatting through formatter chains
- Type-safe formatter registration using Go generics
- Error aggregation with errors.Join
- Independent error registries for package isolation
Basic Usage ¶
Create and wrap errors using the package-level functions:
// simple error wrapping
err := serrors.Wrap("database.connect", dbErr)
// formatted error creation
err := serrors.Wrapf("api.request", "failed with status %d", statusCode)
// multiple error aggregation
err := serrors.Wrap("batch.process", err1, err2, err3)
// wrapping with structured context (see # Structured Error Context)
err := serrors.WrapWith("database.query", QueryCtx{UserID: id, Table: "orders"}, dbErr)
Error Formatting ¶
Errors are formatted compactly in a single line:
"error: operation: <underlying error>"
When multiple errors are joined, they are separated by semicolons:
"error: operation: <first error>; <second error>; <third error>"
Custom Formatters ¶
Register custom formatters for specific error types:
// normal approach
serrors.RegisterFormatFunc(func(err error) (string, bool) {
myErr, ok := err.(*MyError)
if !ok || myErr == nil {
return "", false
}
return fmt.Sprintf("code=%d: %s", myErr.Code, myErr.Message), true
})
// type-safe approach with generics (explicit domain required)
serrors.RegisterTypedFormatFunc(serrors.Default(), func(myErr *MyError) string {
return fmt.Sprintf("code=%d: %s", myErr.Code, myErr.Message)
})
Formatters are consulted in reverse registration order; the last formatter to return ok=true wins (last-match semantics). If no formatter matches, the error's Error() method is used as a fallback.
Domain Isolation ¶
Create independent domains for package isolation:
domain := serrors.New("my-package")
domain.RegisterFormatFunc(myFormatFunc)
// errors bound to this domain use its label and formatters
err := domain.Wrap("operation", underlyingErr)
This prevents formatting conflicts when multiple packages use this library.
Error Inspection ¶
Use standard library error inspection functions or the package-level helpers:
// check error type
var myErr *MyError
if errors.As(err, &myErr) {
// handle MyError
}
// check for sentinel errors
if errors.Is(err, serrors.Default().Root()) {
// error originated from this package
}
// extract *Error and inspect Op in one call
if e, ok := serrors.AsError(err); ok {
fmt.Println("Op:", e.Op)
}
// first non-nil Data of the given type in the chain
ctx, ok := serrors.AnyDataAs[ConnCtx](err)
Package-Level Functions ¶
These functions operate on the default domain:
Default() *Domain // return the current default domain SetDefault(d *Domain) // replace the default domain Reset() // replace the default domain with a fresh stock domain; useful in tests Sentinel(op string) *Error // create a sentinel on the default domain RegisterFormatFunc(fn FormatFunc) func() // register formatter; call returned func to unregister Wrap(op string, errs ...error) error // wrap errors Wrapf(op string, format string, args ...any) error // wrap formatted error WrapWith(op string, data any, errs ...error) error // wrap with structured context WrapWithf(op string, data any, format string, args ...any) error // wrap formatted error with context
Sentinel Hierarchies ¶
Build a typed error hierarchy without fmt.Errorf:
var ErrParent = domain.Sentinel("parent") // *Error, no parent
var ErrChild = ErrParent.Derive("child") // *Error, child of ErrParent
return ErrChild.Detail("closed") // plain literal message
return ErrChild.Detailf("closed after %d retries", n) // formatted message
errors.Is(ErrChild, ErrParent) // true
errors.Is(ErrChild, domain.Root()) // true
Error.Derive returns a *Error and supports errors.As inspection on Op. Error.Detail and Error.Detailf return a plain error wrapping e with a message suffix.
Note that using fmt.Errorf is totally fine and compatible with this package, but the Derive/Detail/Detailf methods provide a more concise way to build error hierarchies without needing to define custom error types or use fmt.Errorf directly.
Domain Methods ¶
These methods operate on specific domains:
d.Label() string d.Root() *Error d.Sentinel(op string) *Error d.Wrap(op string, errs ...error) error d.Wrapf(op string, format string, args ...any) error d.WrapWith(op string, data any, errs ...error) error d.WrapWithf(op string, data any, format string, args ...any) error d.RegisterFormatFunc(fn FormatFunc) func() // use RegisterTypedFormatFunc[T] for type-safe registration d.With(opts ...Option) *Domain // independent copy; passing no options is equivalent to a full clone d.Sub(label string, opts ...Option) *Domain // new domain, linked root (is-a link) d.Contains(err error) bool // report whether err belongs to this domain
Error Methods ¶
These methods are available on *Error values:
e.Derive(op string) *Error // typed child sentinel, same domain e.Detail(msg string) error // plain leaf with literal message e.Detailf(format string, args ...any) error // plain leaf with formatted message e.LogValue() slog.Value // implements slog.LogValuer; plain string or group with [MessageKey]
Standalone Functions ¶
These functions work with any domain or error value:
RegisterTypedFormatFunc[T error](d *Domain, fn func(T) string) func() Walk(err error, fn func(*Error) bool) // walk the chain, call fn for each *Error node; return false to stop AsError(err error) (*Error, bool) // extract the first *Error from the chain AnyDataAs[T any](err error) (T, bool) // first Data in the chain assignable to T AllDataAs[T any](err error) []T // all Data values in the chain assignable to T, outermost first LogAttrs(err error) []log/slog.Attr // walk chain, collect Data as slog attrs CaptureStackTrace() StackTrace // capture call stack, up to [DefaultStackDepth] frames CaptureStackTraceN(depth, skip int) StackTrace // capture at most depth frames, skip extra wrapper frames DefaultFormatter() Formatter // return the built-in Formatter; useful as a base for wrapping the default output FormatError(d *Domain, err error) string // format err as if produced by d; useful in tests
Note: RegisterTypedFormatFunc is a standalone generic function rather than a method due to Go's limitation of type parameters on methods of non-generic types.
Structured Error Context ¶
[d.WrapWith] and [d.WrapWithf] attach arbitrary structured data to a runtime error via the Data field of *Error. This data is excluded from the string returned by Error.Error and is intended for programmatic use: logging, metrics, retry logic, circuit-breaker state, etc ...
[Error.Data] may be any type. Recommended choices:
// 1. Domain struct: statically typed, direct field access, programmatic
type ConnCtx struct{ Host string; Attempt int }
return domain.WrapWith("dial", ConnCtx{host, attempt}, err)
// 2. Domain struct implementing log/slog.LogValuer: automatic slog expansion
func (c ConnCtx) LogValue() slog.Value {
return slog.GroupValue(slog.String("host", c.Host), slog.Int("attempt", c.Attempt))
}
// 3. []log/slog.Attr: quick inline attrs, direct slog pass-through
return domain.WrapWith("dial",
[]slog.Attr{slog.String("host", host), slog.Int("attempt", n)},
err,
)
Retrieve context with errors.As and LogAttrs (if data implements slog.LogValuer):
var e *serrors.Error
if errors.As(err, &e) {
// programmatic: direct type assertion on a single node
if ctx, ok := e.Data.(ConnCtx); ok && ctx.Attempt >= maxRetries {
return ErrGiveUp
}
}
// structured logging: LogAttrs walks the full chain, handles []slog.Attr,
// slog.LogValuer, and arbitrary types (wrapped as slog.Any("data", ...))
slog.LogAttrs(ctx, slog.LevelError, "dial failed",
append(serrors.LogAttrs(err), slog.Any("error", err))...,
)
To collect Data from all layers of a wrapped error chain:
for _, ctx := range serrors.AllDataAs[ConnCtx](err) {
// ctx is the typed Data value from each matching *Error node, outermost first
}
Stack Traces ¶
Stack traces are supported as an opt-in mechanism via the StackTrace type and CaptureStackTrace helper. There is no domain-level flag; the caller captures a trace only where it is explicitly needed, by passing CaptureStackTrace as the data argument to Domain.WrapWith or Domain.WrapWithf:
err := domain.WrapWith("op", serrors.CaptureStackTrace(), cause)
Retrieval uses the existing inspection API, no special functions are needed:
stacks := serrors.AllDataAs[serrors.StackTrace](err)
for _, st := range stacks {
for _, f := range st.Frames() {
fmt.Printf("%s:%d %s\n", f.File, f.Line, f.Function)
}
}
Because StackTrace implements log/slog.LogValuer, it is automatically expanded by LogAttrs and Error.LogValue when stored as [Error.Data]. All resolved frames are joined into a single semicolon-separated string emitted under the key StackTraceKey ("stack"), so the output is human-readable and searchable:
slog.Error("request failed", "err", wrappedErr)
// -> err.message="service: op: cause" err.stack="github.com/user/app/file.go:13 main.initApp; github.com/user/app/main.go:17 main.run"
See StackTrace for pitfalls (Data field conflict, skip depth, frame resolution) and their mitigations.
Concurrency Safety ¶
All Domain operations are safe for concurrent use. See Domain.RegisterFormatFunc and Domain.Wrap for per-method guidance.
Error Chaining ¶
Every *Error compares equal to the root sentinel of its domain via errors.Is. Use Default().Root() to match any error from the current default domain:
if errors.Is(err, serrors.Default().Root()) {
// err originated from the default domain
}
For a custom domain, use its own root:
domain := serrors.New("my-lib")
ErrMyLib := domain.Root()
// errors.Is(err, ErrMyLib) is true for any *Error from domain
Index ¶
- Constants
- func AllDataAs[T any](err error) []T
- func AnyDataAs[T any](err error) (T, bool)
- func FormatError(d *Domain, err error) string
- func LogAttrs(err error) []slog.Attr
- func RegisterFormatFunc(fn FormatFunc) func()
- func RegisterTypedFormatFunc[T error](d *Domain, fn func(T) string) func()
- func Reset()
- func SetDefault(d *Domain)
- func Walk(err error, fn func(*Error) bool)
- func Wrap(op string, errs ...error) error
- func WrapWith(op string, data any, errs ...error) error
- func WrapWithf(op string, data any, format string, args ...any) error
- func Wrapf(op string, format string, args ...any) error
- type Delimiters
- type Domain
- func (d *Domain) Contains(err error) bool
- func (d *Domain) Label() string
- func (d *Domain) RegisterFormatFunc(fn FormatFunc) (unregister func())
- func (d *Domain) Root() *Error
- func (d *Domain) Sentinel(op string) *Error
- func (d *Domain) Sub(label string, opts ...Option) *Domain
- func (d *Domain) With(opts ...Option) *Domain
- func (d *Domain) Wrap(op string, errs ...error) error
- func (d *Domain) WrapWith(op string, data any, errs ...error) error
- func (d *Domain) WrapWithf(op string, data any, format string, args ...any) error
- func (d *Domain) Wrapf(op string, format string, args ...any) error
- type Error
- type FormatFunc
- type FormatSpec
- type Formatter
- type FormatterFunc
- type Option
- func WithBase(base *Domain) Option
- func WithDelimiters(d Delimiters) Option
- func WithFormatFunc(fn FormatFunc) Option
- func WithFormatter(f Formatter) Option
- func WithJoinDelimiter(delim string) Option
- func WithLabel(label string) Option
- func WithLabelDelimiter(delim string) Option
- func WithPartDelimiter(delim string) Option
- type StackTrace
Examples ¶
Constants ¶
const ( // DataKey is the slog attribute key used when [LogAttrs] falls back to wrapping // an arbitrary Data value as a single [slog.Any] attribute. This occurs when Data is // neither a []slog.Attr slice nor a [slog.LogValuer] that resolves to a group. DataKey = "data" // MessageKey is the attribute key used for the error message in the slog group value // produced by [Error.LogValue] when the error chain contains structured [Error.Data]. // When no structured data is present, [Error.LogValue] returns a plain string value // and this key is not emitted. This mirrors the convention of [log/slog.MessageKey]. MessageKey = "message" // StackTraceKey is the slog attribute key used for the stack trace string in the // group value produced by [StackTrace.LogValue]. It mirrors the [MessageKey] convention. StackTraceKey = "stack" )
const DefaultStackDepth = 32
DefaultStackDepth is the maximum number of frames captured by CaptureStackTrace. Pass it to CaptureStackTraceN to match the default behavior:
serrors.CaptureStackTrace() // default 32 frames serrors.CaptureStackTraceN(serrors.DefaultStackDepth, 0) // identical
Variables ¶
This section is empty.
Functions ¶
func AllDataAs ¶
AllDataAs walks the error chain and returns the Data value from every *Error node whose Data is assignable to T, ordered from outermost to innermost. It is the typed counterpart of AnyDataAs for collecting Data from all layers. T can be interface{} to collect all non-nil Data values regardless of type.
Example ¶
ExampleAllDataAs demonstrates collecting typed Data from every *Error node in the chain, ordered outermost to innermost.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
inner := d.WrapWith("connect", "host=db-primary", errors.New("timeout"))
outer := d.WrapWith("retry", "attempt=2", inner)
for _, s := range serrors.AllDataAs[string](outer) {
fmt.Println(s)
}
}
Output: attempt=2 host=db-primary
func AnyDataAs ¶
AnyDataAs returns the first Data value in the chain that is assignable to T, searching from outermost to innermost, and reports whether one was found. It skips *Error nodes whose Data is not assignable to T. Use AllDataAs to collect matching Data from every layer. T can be interface{} to collect any non-nil Data values regardless of type.
Example ¶
ExampleAnyDataAs demonstrates retrieving the first typed Data value found anywhere in the error chain.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
err := d.WrapWith("op", "request-id=abc123", errors.New("not found"))
id, ok := serrors.AnyDataAs[string](err)
fmt.Println(ok)
fmt.Println(id)
}
Output: true request-id=abc123
func FormatError ¶
FormatError formats err as if it were produced by d, applying d's label, FormatFunc chain, and Formatter. It is useful for validating custom formatter configurations in tests without constructing errors through the normal API.
func LogAttrs ¶
LogAttrs walks the error chain and returns log/slog.Attr values collected from every *Error node that has a non-nil Data, aggregated outermost to innermost. It is the slog-oriented counterpart to [AllData].
Each node's Data is converted according to the same rules documented on [Error.Data]:
- []log/slog.Attr: returned verbatim.
- log/slog.LogValuer that resolves to a group: the group's attributes.
- Any other type: wrapped as a single slog.Any(DataKey, ...) attribute.
Example:
slog.LogAttrs(ctx, slog.LevelError, "request failed",
append(serrors.LogAttrs(err), slog.Any("error", err))...,
)
Example ¶
ExampleLogAttrs demonstrates collecting slog attributes from every *Error node in the chain, outermost first. Nodes without Data are silently skipped.
package main
import (
"errors"
"fmt"
"log/slog"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
inner := d.WrapWith("connect",
[]slog.Attr{slog.String("host", "db"), slog.Int("attempt", 1)},
errors.New("timeout"),
)
outer := d.Wrap("request", inner)
for _, attr := range serrors.LogAttrs(outer) {
fmt.Printf("%s=%v\n", attr.Key, attr.Value)
}
}
Output: host=db attempt=1
func RegisterFormatFunc ¶
func RegisterFormatFunc(fn FormatFunc) func()
RegisterFormatFunc calls RegisterFormatFunc on the default domain. It returns a cleanup function that removes the formatter when called.
func RegisterTypedFormatFunc ¶
RegisterTypedFormatFunc appends a type-safe formatter for errors of type T to the given domain's formatter chain and returns a cleanup function that removes it when called. The formatter fn is only called when the error is of type T; no manual type assertion needed. The last registered formatter to return ok=true wins (last-match semantics). Safe to call at any time; the returned function removes the formatter atomically.
This is a standalone generic function rather than a method because Go does not support type parameters on methods of non-generic types.
Example:
RegisterTypedFormatFunc(domain, func(e *MyError) string {
return fmt.Sprintf("my error: %s", e.Details)
})
Example ¶
ExampleRegisterTypedFormatFunc demonstrates a type-safe format function for a concrete third-party error type. The nil check and type assertion are handled automatically by the generic wrapper.
package main
import (
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
// httpError is a custom error type used to demonstrate RegisterTypedFormatFunc.
type httpError struct {
Code int
Text string
}
func (e *httpError) Error() string {
return fmt.Sprintf("%d %s", e.Code, e.Text)
}
func main() {
d := serrors.New("my-app")
unregister := serrors.RegisterTypedFormatFunc(d, func(e *httpError) string {
return fmt.Sprintf("HTTP %d (%s)", e.Code, e.Text)
})
defer unregister()
err := d.Wrap("request", &httpError{404, "Not Found"})
fmt.Println(err)
}
Output: my-app: request: HTTP 404 (Not Found)
func Reset ¶
func Reset()
Reset replaces the default domain with a fresh New(defaultLabel) instance, clearing all configuration including label, formatters, and delimiters. After Reset, the default domain is equivalent to the initial package state.
NOTE: Intended for use in tests only. Must not be called in parallel tests, as it replaces the global default domain and will race with goroutines that concurrently read or write it.
NOTE: Reset does not affect *Error values already in existence. Errors produced by the old default domain retain their Domain pointer and continue to match the old domain root.
func SetDefault ¶
func SetDefault(d *Domain)
SetDefault replaces the default domain used by all package-level functions. After calling SetDefault, use Default().Root() to obtain the active default's root sentinel.
func Walk ¶
Walk calls fn for each *Error in the error chain, outermost first. Return false from fn to stop the walk early. Walk is the building block for custom chain inspection; use AnyDataAs or AllDataAs for the common cases of finding typed Data values.
Example ¶
ExampleWalk demonstrates walking every *Error node in an error chain, from outermost to innermost.
package main
import (
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
ErrStore := d.Sentinel("store")
ErrQuery := ErrStore.Derive("query")
err := d.Wrap("handler", ErrQuery)
serrors.Walk(err, func(e *serrors.Error) bool {
fmt.Printf("op=%q\n", e.Op)
return true
})
}
Output: op="handler" op="query" op="store"
func Wrap ¶
Wrap calls Wrap on the default domain. If all provided errors are nil, Wrap returns nil (consistent with the behavior of errors.Join).
func WrapWith ¶
WrapWith calls Domain.WrapWith on the default domain. See Domain.WrapWith for the semantics and conventions of data. If all provided errors are nil, WrapWith returns nil.
func WrapWithf ¶
WrapWithf calls Domain.WrapWithf on the default domain. See Domain.WrapWithf for the semantics and conventions of data.
Types ¶
type Delimiters ¶
type Delimiters struct {
// Label separates the domain label from the first op or tail. Default: ": ".
Label string
// Part separates individual ops from each other and from the tail. Default: ": ".
Part string
// Join separates individual child errors of an [errors.Join] aggregate. Default: "; ".
Join string
}
Delimiters controls the three delimiter strings used when formatting errors produced by a Domain. To customize individual fields, use a struct literal or the dedicated With*Delimiter options:
delims := serrors.Delimiters{Label: ": ", Part: ": ", Join: "; "} // start from defaults
delims.Join = " & " // override just one
domain := serrors.New("app", serrors.WithDelimiters(delims))
// or set a single field without a struct literal:
domain := serrors.New("app", serrors.WithPartDelimiter(" > "))
Example ¶
ExampleDelimiters demonstrates replacing the default ": " delimiters.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("service",
serrors.WithDelimiters(serrors.Delimiters{
Label: " | ",
Part: " -> ",
Join: " & ",
}),
)
err := d.Wrap("handler", errors.New("request failed"))
fmt.Println(err)
}
Output: service | handler -> request failed
type Domain ¶
type Domain struct {
// contains filtered or unexported fields
}
Domain holds the label and formatter chain for an isolated error domain. Use New to create an independent instance; use the package-level functions to operate on the shared default domain.
func Default ¶
func Default() *Domain
Default returns the current default domain. All package-level functions (Wrap, RegisterFormatFunc, etc ...) delegate to it.
Note: the returned pointer may become stale if SetDefault or Reset is subsequently called; callers that store the result must account for this.
func New ¶
New creates a new Domain with the given label and optional configuration [Option]s. Options are applied in order; later options for the same field overwrite earlier ones.
An empty label is valid: DefaultFormatter will omit the label prefix entirely, producing "op: underlying" instead of "label: op: underlying". Prefer a non-empty label for the default domain to avoid ambiguous output.
Example ¶
ExampleNew demonstrates creating a domain and wrapping a basic error.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
err := d.Wrap("database connect", errors.New("connection refused"))
fmt.Println(err)
}
Output: my-app: database connect: connection refused
func (*Domain) Contains ¶
Contains reports whether err belongs to this domain. It returns true when errors.Is(err, d.Root()) returns true, which includes errors produced by this domain directly or by any Domain.Sub descendant.
func (*Domain) RegisterFormatFunc ¶
func (d *Domain) RegisterFormatFunc(fn FormatFunc) (unregister func())
RegisterFormatFunc appends fn to the formatter chain and returns a cleanup function that removes the formatter when called. The last registered formatter to return ok=true wins (last-match semantics). Safe to call at any time; the returned function removes the formatter atomically.
RegisterFormatFunc is safe for concurrent use, including concurrent calls to error formatting and other Domain.RegisterFormatFunc calls on this domain. Typically called once in an init function.
func (*Domain) Root ¶
Root returns the root sentinel error for this domain. All *Error values created through this domain match it via errors.Is.
NOTE: The root sentinel is intended for errors.Is matching. Calling Error() on it directly produces a minimal string (the domain label alone) and it should not be returned as a standalone error value.
func (*Domain) Sentinel ¶
Sentinel creates a new top-level *Error sentinel with the given operation name bound to this domain. It has no underlying error (Err is nil).
Example ¶
ExampleDomain_Sentinel demonstrates building a sentinel error hierarchy and verifying that errors.Is matches any ancestor in the tree.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
ErrNetwork := d.Sentinel("network")
ErrNetworkDial := ErrNetwork.Derive("dial")
ErrNetworkTLS := ErrNetwork.Derive("tls handshake")
fmt.Println(ErrNetwork)
fmt.Println(ErrNetworkDial)
fmt.Println(errors.Is(ErrNetworkDial, ErrNetwork))
fmt.Println(errors.Is(ErrNetworkTLS, ErrNetwork))
fmt.Println(errors.Is(ErrNetworkDial, ErrNetworkTLS))
}
Output: my-app: network my-app: network: dial true true false
func (*Domain) Sub ¶
Sub returns a new Domain for a sub-domain of d. Like Domain.With, it applies opts to the copy, but additionally links the child's root to d's root, establishing an is-a relationship: errors.Is(err, d.Root()) returns true for any error produced by the returned domain or any further Domain.Sub domains derived from it.
Sub is a one-way declaration: once linked, all existing and future errors from the child domain satisfy errors.Is checks against d.Root() and any of d's own ancestor roots (if d was itself created via Sub).
Example ¶
ExampleDomain_Sub demonstrates creating a sub-domain with an is-a link to its parent. Errors from the child satisfy errors.Is against the parent's root.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
platform := serrors.New("platform")
payments := platform.Sub("payments")
err := payments.Wrap("charge", errors.New("card declined"))
fmt.Println(err)
fmt.Println(errors.Is(err, payments.Root()))
fmt.Println(errors.Is(err, platform.Root())) // true via Sub link
}
Output: payments: charge: card declined true true
func (*Domain) With ¶
With returns a new Domain that is a copy of the receiver with the given [Option]s applied. The new domain has an independent root sentinel: errors from it do NOT satisfy errors.Is against the receiver's root. Use Domain.Sub when an is-a relationship is required.
Calling With with no options is equivalent to a full independent copy of the domain.
Common options: WithLabel (rename without an is-a link), WithFormatFunc, WithFormatter, WithDelimiters, WithLabelDelimiter, WithPartDelimiter, WithJoinDelimiter.
func (*Domain) Wrap ¶
Wrap aggregates one or more underlying errors using errors.Join when appropriate. It returns an error whose concrete type is *Error, bound to this domain, so formatting uses this domain's label and formatters. If all provided errors are nil, Wrap returns nil (consistent with the behavior of errors.Join). An empty op string is silently omitted from the formatted output, producing "label: underlying".
Example (Multiple) ¶
ExampleDomain_Wrap_multiple demonstrates wrapping multiple errors in a single call. The result is a compact single-line message delimited by semicolons.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
err := d.Wrap("batch",
errors.New("item 1: invalid"),
errors.New("item 3: timeout"),
)
fmt.Println(err)
}
Output: my-app: batch: item 1: invalid; item 3: timeout
func (*Domain) WrapWith ¶
WrapWith is like Domain.Wrap but attaches arbitrary structured data to the error. data is stored in [Error.Data] and is retrievable via errors.As; it is not included in the string representation returned by Error.Error.
data may be any type. Common choices:
- A domain-specific context struct, for programmatic inspection by callers (e.g. retry logic, circuit-breaker state, metrics dimensions).
- A []log/slog.Attr slice, for direct pass-through to log/slog.LogAttrs.
- Any type implementing log/slog.LogValuer, which LogAttrs will automatically expand into slog attributes.
If all provided errors are nil, WrapWith returns nil.
Example - domain struct with log/slog.LogValuer:
type ConnCtx struct{ Host string; Attempt int }
func (c ConnCtx) LogValue() slog.Value {
return slog.GroupValue(
slog.String("host", c.Host),
slog.Int("attempt", c.Attempt),
)
}
return domain.WrapWith("dial", ConnCtx{host, attempt}, err)
// retrieve and log (LogAttrs walks the full chain automatically):
slog.LogAttrs(ctx, slog.LevelError, "dial failed",
append(serrors.LogAttrs(err), slog.Any("error", err))...,
)
Example: raw slog attributes:
return domain.WrapWith("dial",
[]slog.Attr{slog.String("host", host), slog.Int("attempt", n)},
err,
)
Example ¶
ExampleDomain_WrapWith demonstrates attaching structured data to an error. The data is excluded from the error string but accessible via errors.As.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
type ConnCtx struct {
Host string
Attempt int
}
d := serrors.New("my-app")
err := d.WrapWith("dial", ConnCtx{"db-primary", 3}, errors.New("connection refused"))
// Data is intentionally excluded from the error string.
fmt.Println(err)
// Retrieve the structured context programmatically.
var e *serrors.Error
if errors.As(err, &e) {
ctx := e.Data.(ConnCtx)
fmt.Printf("host=%s attempt=%d\n", ctx.Host, ctx.Attempt)
}
}
Output: my-app: dial: connection refused host=db-primary attempt=3
func (*Domain) WrapWithf ¶
WrapWithf is like Domain.Wrapf but attaches arbitrary structured data to the error. See Domain.WrapWith for the semantics and conventions of data.
type Error ¶
type Error struct {
// Op is the operation/operator/component that failed.
Op string
// Err is the underlying error.
Err error
// Data is optional structured context; nil for sentinels and plain Wrap calls.
// Attach via [Domain.WrapWith] or [Domain.WrapWithf].
// Retrieve via [LogAttrs] (slog-friendly, walks the chain) or a direct type assertion.
// Common types: a domain struct, [][log/slog.Attr], or any [log/slog.LogValuer].
Data any
// Domain is the domain this error belongs to.
// nil means use [Default]() at the time Error() or errors.Is is called.
// All errors produced by the package API always have a non-nil Domain.
Domain *Domain
}
Error is the structured error type produced by this package. It contains operation context and supports error chaining. Use errors.As to inspect the Op, Data, and Domain fields for operation context.
Errors are formatted compactly in a single line:
"error: operation: <underlying error>"
When multiple errors are joined, they are separated by semicolons:
"error: operation: <first error>; <second error>; <third error>"
The Data field is intentionally excluded from the string representation; use LogAttrs or a direct type assertion on Data to access it.
Construction ¶
*Error values are normally created through the package API:
- Domain.Sentinel: creates a named sentinel bound to a domain.
- Domain.Wrap, Domain.Wrapf, Domain.WrapWith, Domain.WrapWithf: wraps errors.
- Error.Derive: creates a child sentinel in the same domain.
Direct struct construction (&Error{...}) is also valid. When the Domain field is set explicitly, the error behaves exactly as if produced by the package API. A nil Domain follows the nil-means-default convention: Default() is used at the time Error() or errors.Is is called. This matches the behavior of standard library types such as net/http.Client.
The only valid use of a bare *Error variable without setting Domain is as an errors.As target:
var e *Error
if errors.As(err, &e) { ... }
func AsError ¶
AsError extracts the first *Error from the chain and reports whether one was found. It is a convenience wrapper around errors.As for the common case of inspecting [Error.Op], [Error.Data], or [Error.Domain] from a wrapped error.
Example ¶
ExampleAsError demonstrates extracting the first *Error from a chain without declaring a temporary variable.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
err := d.Wrap("read file", errors.New("no such file"))
e, ok := serrors.AsError(err)
fmt.Println(ok)
fmt.Println(e.Op)
}
Output: true read file
func (*Error) Derive ¶
Derive creates a new *Error sentinel that is a direct child of e, bound to the same domain. Unlike Error.Detail, the result is a full *Error value, so callers can inspect its Op field via errors.As and it participates in the same domain chain-walk formatting as its parent.
Example:
var ErrService = domain.Sentinel("service")
var ErrServiceClosed = ErrService.Derive("closed")
// ErrServiceClosed.Error() == "label: service: closed"
// errors.Is(ErrServiceClosed, ErrService) == true
func (*Error) Detail ¶
Detail creates a plain leaf error that wraps e with the given message. The result is a standard error, not *Error; use Error.Derive when a typed sentinel is needed. See Error.Detailf for the full design rationale of the return type and delimiter handling.
Example:
var ErrService = domain.Sentinel("service")
return ErrService.Detail("connection refused")
Example ¶
ExampleError_Detail demonstrates attaching a literal message to a sentinel as a plain leaf error. The result participates in errors.Is but is not *Error.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
ErrService := d.Sentinel("service")
err := ErrService.Detail("upstream unavailable")
fmt.Println(err)
fmt.Println(errors.Is(err, ErrService))
}
Output: my-app: service: upstream unavailable true
func (*Error) Detailf ¶
Detailf creates a plain leaf error that wraps e with a formatted message. Delegates to fmt.Errorf, so %w is fully supported: any error wrapped with %w is reachable via errors.Is and errors.As from the result. The result is a standard error, not *Error; use Error.Derive when a typed sentinel is needed.
Design Note ¶
The return type is error, not *Error, for two reasons:
- Storing the message in [Error.Op] would misuse a field that is structurally an operation name; errors.As traversal would surface it where an op is expected.
- The implementation delegates to fmt.Errorf, which returns *fmt.wrapError. Returning *Error from that path requires reimplementing %w from scratch.
The delimiter is read from the domain and baked into the format string at call time. This is not a practical concern: domain delimiters are immutable after construction.
Example:
var ErrService = domain.Sentinel("service")
return ErrService.Detailf("connection to %q refused after %d retries", host, n)
// inner error is also wrapped and reachable via errors.Is:
return ErrService.Detailf("operation failed: %w", pkgErr)
Example ¶
ExampleError_Detailf demonstrates attaching a formatted message to a sentinel. %w is supported, so any wrapped error remains reachable via errors.Is.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
ErrNetwork := d.Sentinel("network")
cause := errors.New("i/o timeout")
err := ErrNetwork.Detailf("dial %q: %w", "db:5432", cause)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNetwork))
fmt.Println(errors.Is(err, cause))
}
Output: my-app: network: dial "db:5432": i/o timeout true true
func (*Error) Is ¶
Is makes any *Error compare equal to the root sentinel of its domain and, for Sub domains, to any ancestor domain root in the chain. This ensures errors.Is(err, ErrSomeRoot) returns true for any *Error belonging to the same domain or a Sub-derived descendant domain.
func (*Error) LogValue ¶
LogValue implements log/slog.LogValuer, allowing *Error values to be passed directly to slog methods and emit structured output automatically.
When no *Error node in the chain carries structured Data, LogValue returns a plain string value equivalent to e.Error(), identical to passing a plain error to slog. This ensures zero regression for errors without attached data.
When at least one node carries Data, LogValue returns a slog group value whose first attribute is the full error message under the key MessageKey, followed by all structured attributes collected by LogAttrs. Slog emits the group nested under the caller's key:
// no Data anywhere in chain:
slog.Error("dial failed", "err", wrappedErr)
// -> err="service: dial: connection refused"
// with Data (e.g. ConnCtx implements slog.LogValuer):
slog.Error("dial failed", "err", wrappedErr)
// -> err.message="service: dial: connection refused" err.host="db" err.attempt=2
Example ¶
ExampleError_LogValue demonstrates that *Error implements slog.LogValuer. Without Data it returns a plain string value; with Data it returns a slog group.
package main
import (
"errors"
"fmt"
"log/slog"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
// no Data: LogValue returns a plain string value
e1, _ := serrors.AsError(d.Wrap("op", errors.New("fail")))
fmt.Println(e1.LogValue().Kind())
// with Data: LogValue returns a slog group (message + data attributes)
e2, _ := serrors.AsError(d.WrapWith("op",
[]slog.Attr{slog.String("host", "db")},
errors.New("fail"),
))
fmt.Println(e2.LogValue().Kind())
}
Output: String Group
type FormatFunc ¶
FormatFunc formats an error to a compact string representation. Return ("", false) if this formatter does not handle the given error type.
The formatter chain uses last-match semantics: the last registered FormatFunc to return ok=true wins. A catch-all formatter (one that returns true for every error), silently shadows ALL previously registered formatters for that domain. Register catch-all formatters first (lowest priority) and narrow, type-specific ones last (highest priority).
type FormatSpec ¶
type FormatSpec struct {
// Label is the domain label (e.g. "error", "my-app"). An empty label omits the label prefix.
Label string
// Ops is the operation chain, ancestor-first (outermost to innermost). May be empty.
Ops []string
// Delimiters contains the domain's configured delimiter strings.
Delimiters Delimiters
// contains filtered or unexported fields
}
FormatSpec carries the formatting pipeline for a domain snapshot. It is passed to [Formatter.Format] alongside the leaf error, so the formatter can produce any desired string representation without being constrained by pre-assembled pieces.
FormatSpec is pure data, it holds no hidden state. Use FormatSpec.Apply to format any error through the domain's FormatFunc chain.
func (FormatSpec) Apply ¶
func (spec FormatSpec) Apply(err error) string
Apply formats err through the domain's FormatFunc chain using last-match semantics, applying the spec's label and delimiters, and falling back to err.Error() when no registered formatter matches.
Use this inside a [Formatter.Format] implementation to format any error, both the primary leaf and any children of an errors.Join aggregate:
return fmt.Sprintf("[%s] %s: %s", spec.Label, strings.Join(spec.Ops, "/"), spec.Apply(err))
type Formatter ¶
type Formatter interface {
Format(err error, spec FormatSpec) string
}
Formatter is the full-control interface for assembling an error's string representation. It receives the leaf error and a FormatSpec holding the domain's label, ops, delimiters, and FormatFunc chain. Set on a Domain via WithFormatter.
Use DefaultFormatter when only wrapping or post-processing the default output is needed (rather than replacing the entire assembly), to avoid reimplementing the full assembly logic and ensure consistent application of Delimiters and [FormatFunc]s.
Use FormatterFunc to implement Formatter with a plain function.
func DefaultFormatter ¶
func DefaultFormatter() Formatter
DefaultFormatter returns the built-in Formatter implementation. It applies the FormatFunc chain, uses [Delimiters.Join] for errors.Join aggregates, and assembles the final string as:
label + Delimiters.Label + ops[0] + Delimiters.Part + ... + Delimiters.Part + tail
DefaultFormatter is useful as a base when you want to wrap the default output:
formatter := serrors.DefaultFormatter()
domain := serrors.New("app",
serrors.WithFormatter(serrors.FormatterFunc(
func(err error, spec serrors.FormatSpec) string {
return "[" + formatter.Format(err, spec) + "]"
},
)),
)
type FormatterFunc ¶
type FormatterFunc func(err error, spec FormatSpec) string
FormatterFunc is a function type that implements Formatter. It allows using a plain function wherever a Formatter is expected.
Example:
domain := serrors.New("app",
serrors.WithFormatter(serrors.FormatterFunc(
func(err error, spec serrors.FormatSpec) string {
return fmt.Sprintf("[%s] %s: %s",
spec.Label,
strings.Join(spec.Ops, "/"),
spec.Apply(err))
},
)),
)
Example ¶
ExampleFormatterFunc demonstrates a full-control Formatter that replaces the default delimiter-based assembly with a custom layout.
package main
import (
"errors"
"fmt"
"strings"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app",
serrors.WithFormatter(serrors.FormatterFunc(
func(err error, spec serrors.FormatSpec) string {
path := strings.Join(spec.Ops, "/")
if path == "" {
return fmt.Sprintf("[%s] %s", spec.Label, spec.Apply(err))
}
return fmt.Sprintf("[%s/%s] %s", spec.Label, path, spec.Apply(err))
},
)),
)
err := d.Wrap("handler", errors.New("request timeout"))
fmt.Println(err)
}
Output: [my-app/handler] request timeout
func (FormatterFunc) Format ¶
func (f FormatterFunc) Format(err error, spec FormatSpec) string
Format implements Formatter.
type Option ¶
type Option func(*Domain)
Option configures a Domain at construction time or when passed to Domain.With or Domain.Sub. Options are applied in the order they are provided.
func WithBase ¶
WithBase establishes an is-a link between the domain and base. Errors from the domain will satisfy errors.Is checks against base.Domain.Root and any of base's ancestor roots.
This is the composable counterpart of Domain.Sub; use it with New or Domain.With when the sub-domain label is set separately or when composing multiple options:
child := serrors.New("child", serrors.WithBase(base))
child := serrors.New("child", serrors.WithBase(base), serrors.WithDelimiters(custom))
Domain.Sub is a shorthand that combines a label change and WithBase internally.
The base chain must be acyclic. If base already (directly or transitively) uses the calling domain as its own base, the resulting root chain contains a cycle and the behavior of errors.Is on any error from this domain is undefined.
func WithDelimiters ¶
func WithDelimiters(d Delimiters) Option
WithDelimiters sets all three delimiter strings for the domain. See Delimiters for the role of each delimiter, default values, and its interaction with Formatter.
func WithFormatFunc ¶
func WithFormatFunc(fn FormatFunc) Option
WithFormatFunc appends a single FormatFunc to the domain's formatter chain. It is the construction-time equivalent of Domain.RegisterFormatFunc. When providing multiple formatters at construction time, list more specific or higher-priority formatters last so they take precedence (see FormatFunc for last-match semantics):
serrors.New("app",
serrors.WithFormatFunc(formatterA), // consulted first, lower priority
serrors.WithFormatFunc(formatterB), // consulted last, higher priority
)
func WithFormatter ¶
WithFormatter sets a Formatter on the domain. When non-nil, the formatter replaces the default delimiter-based assembly by receiving the leaf error and a FormatSpec and returning the final string. Use FormatterFunc to adapt a plain function.
Example - custom layout with pipe delimiters:
domain := serrors.New(
"app",
serrors.WithDelimiters(serrors.Delimiters{Label: " | ", Part: " > ", Join: " & "}),
serrors.WithFormatter(serrors.FormatterFunc(
func(err error, spec serrors.FormatSpec) string {
return fmt.Sprintf("[%s] %s: %s",
spec.Label, strings.Join(spec.Ops, "/"), spec.Apply(err))
},
)),
)
func WithJoinDelimiter ¶
WithJoinDelimiter sets Delimiters.Join for the domain. See Delimiters for the role of the join delimiter inside errors.Join formatting.
func WithLabel ¶
WithLabel sets the domain label. It is the option counterpart of the label argument to New, and is primarily useful in Domain.With to rename a copy without establishing an errors.Is link to the original:
child := parent.With(serrors.WithLabel("renamed"))
An empty label is valid and suppresses the label prefix in the formatted output. See New for details.
Using WithLabel in Domain.Sub overrides the required label argument; prefer passing the label directly to Sub instead.
func WithLabelDelimiter ¶
WithLabelDelimiter sets Delimiters.Label for the domain. See Delimiters.
func WithPartDelimiter ¶
WithPartDelimiter sets Delimiters.Part for the domain. See Delimiters for the role of the part delimiter in the default top-level assembly.
type StackTrace ¶
type StackTrace []uintptr
StackTrace holds raw program counters captured at a call site. Use CaptureStackTrace to obtain a value and StackTrace.Frames to resolve the counters to human-readable runtime.Frame values.
StackTrace implements log/slog.LogValuer so that when it is stored as [Error.Data] it is automatically expanded by LogAttrs and Error.LogValue into a single slog string attribute keyed by StackTraceKey ("stack"), with all resolved frames semicolon-separated in "file:line function" format:
slog.Error("request failed", "err", wrappedErr)
// -> err.message="service: op: cause" err.stack="github.com/user/myapp/store.go:42 main.openDB; github.com/user/myapp/main.go:17 main.run"
Retrieve from an error chain with AllDataAs:
stacks := serrors.AllDataAs[serrors.StackTrace](err)
for _, st := range stacks {
for _, f := range st.Frames() {
fmt.Printf("%s:%d %s\n", f.File, f.Line, f.Function)
}
}
func CaptureStackTrace ¶
func CaptureStackTrace() StackTrace
CaptureStackTrace captures the current goroutine's call stack and returns it as a StackTrace, collecting up to DefaultStackDepth frames. The first frame is the direct caller of CaptureStackTrace.
Use CaptureStackTraceN when you need a specific frame limit or skip control.
CaptureStackTrace is intended to be called as a Data argument to Domain.WrapWith or Domain.WrapWithf at the precise call site where context should be captured:
err := domain.WrapWith("op", serrors.CaptureStackTrace(), cause)
See StackTrace for retrieval and slog integration details.
Example ¶
ExampleCaptureStackTrace demonstrates attaching an opt-in stack trace to an error. The trace is stored as Error.Data and retrieved via AllDataAs.
package main
import (
"errors"
"fmt"
"github.com/MarwanAlsoltany/serrors"
)
func main() {
d := serrors.New("my-app")
err := d.WrapWith("op", serrors.CaptureStackTrace(), errors.New("something failed"))
stacks := serrors.AllDataAs[serrors.StackTrace](err)
if len(stacks) > 0 {
frames := stacks[0].Frames()
// the first frame is always the direct call site
fmt.Println(len(frames) > 0)
fmt.Println(frames[0].Function != "")
}
}
Output: true true
func CaptureStackTraceN ¶
func CaptureStackTraceN(depth, skip int) StackTrace
CaptureStackTraceN is like CaptureStackTrace but gives explicit control over both the frame limit and additional wrapper frames to skip. depth must be greater than zero; passing zero or a negative value panics. skip is the number of additional frames to skip above CaptureStackTraceN's direct caller; must be >= 0, panics otherwise. Use skip when CaptureStackTraceN is called from inside a helper function, pass 1 for each wrapper layer between the call site and CaptureStackTraceN:
func wrapWithTrace(op string, err error) error {
return domain.WrapWith(op,
serrors.CaptureStackTraceN(serrors.DefaultStackDepth, 1),
err)
}
func (StackTrace) Frames ¶
func (st StackTrace) Frames() []runtime.Frame
Frames resolves the raw program counters in st to runtime.Frame values. Returns nil if st is empty. Frames are ordered from outermost caller (index 0) to innermost (runtime internals).
func (StackTrace) LogValue ¶
func (st StackTrace) LogValue() slog.Value
LogValue implements log/slog.LogValuer. It resolves all frames and emits them as a single semicolon-separated string under the key StackTraceKey ("stack"), wrapped in a slog group so that LogAttrs and Error.LogValue naturally nest it under the caller's key:
slog.Error("failed", "err", wrappedErr)
// -> err.message="service: op: cause" err.stack="github.com/user/myapp/store.go:42 main.openDB; github.com/user/myapp/main.go:17 main.run"
Each frame is formatted as "file:line function", where the file path has build-environment prefixes trimmed (see StackTrace for details). An empty StackTrace emits an empty group (no StackTraceKey attribute is produced).
This is called automatically by LogAttrs and Error.LogValue when a StackTrace is stored as [Error.Data]. No manual invocation is needed for slog integration.