serrors

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 11, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

serrors - Structured Errors for Go

CI Go Reference Go Report Card Coverage

Go's standard error handling is minimal by design. It provides errors.New, fmt.Errorf, errors.Unwrap, errors.Join, errors.Is, errors.As, and then gets out of the way. For small programs, that is exactly right. For larger systems built from multiple layers and packages, the minimal approach leaves much to be desired and several practical problems unsolved.

The key insight the standard library encodes — and that many codebases fail to act on — is that errors are values. Unlike exceptions, they are not just signals that "something went wrong"; they carry meaning, context, and identity that can be inspected, matched, and acted upon programmatically. fmt.Errorf with %w gets partway there, but it stops at wrapping: the resulting value carries the chain but exposes none of the structure. serrors exists to fill that gap. It takes that idea to its logical conclusion: errors are first-class, typed, structured values with an identity, an operation chain, attached data, and a formatting contract.

The name follows the same convention as slog to log: the s stands for structured. Just as slog brought key-value context to logging, serrors brings structure, hierarchy, custom formatting, and inspection to errors without breaking compatibility with the standard library or introducing foreign concepts.

serrors is opinionated. It makes deliberate choices about error message format, delimiter conventions, and the shape of the public API. If any of those choices fit your codebase well, serrors will be a natural starting point. If they conflict with existing conventions, the formatting pipeline is customizable enough to accommodate most requirements, and the library is small enough to fork or vendor if deeper changes are needed. This is the kind of library you'll know when you need it, it is not meant for every use case.

The Problem

1. Errors lose context as they travel up the stack

A common Go pattern is:

if err != nil {
    return fmt.Errorf("database connect: %w", err)
}

This works, but there is no standard way to extract "database connect" programmatically. If the failed operation needs to be logged or reacted upon, the code must parse the error string. If tests or middleware need to match on the operation, they cannot do so reliably.

2. Flat sentinel errors do not compose

The standard approach to sentinel errors is:

var ErrNotFound         = errors.New("not found")
var ErrUserNotFound     = errors.New("user not found")
var ErrPermissionDenied = errors.New("permission denied")

These are flat, independent values. There is no way to express that ErrUserNotFound is a specialization of ErrNotFound. A caller that wants to handle all "not found" variants must enumerate every sentinel explicitly. Adding a new one requires updating every errors.Is check that should cover it, a maintenance problem that grows with the error tree and application complexity.

3. There is no standard format for error messages

fmt.Errorf("%w: %s", ErrBase, "detail") is the de-facto way to attach detail to a sentinel at runtime. It returns a *fmt.wrapError, not a structured type. The standard library imposes no structure on the resulting string, so every package invents its own conventions. When the underlying error already contains a label prefix, wrapping it again produces "app: op: app: nested op: cause"; a doubled label with no way to strip it. There is also no way to register a custom formatter for a third-party error type that appears somewhere deep in the chain.

4. Structured data cannot be attached to an error without a custom type

Errors that carry context (a request ID, a host name, a retry count) have no standard home for that data. The options are to embed the values in the error string (losing machine-readability), or to define a bespoke error struct for every context type (boilerplate at every layer of the stack). Neither composes well across package boundaries.

5. There is no call-site location without a debugger

By the time an error reaches a log statement or a top-level handler, there is no record of which file and line it came from. The only options are to print a full stack trace on every error (noise) or to manually attach caller information (boilerplate that is easy to forget).

6. Logging a rich error requires inspection boilerplate

There is no standard way to pass a structured error to slog and get key-value output. The typical pattern is a chain of errors.As calls that extract fields and build attributes manually; duplicating structure that is already inside the error and coupling the logging code to the error's internals.

The Solution

serrors solves these problems with a small set of composable ideas:

  1. Domain isolation: a Domain owns a label and formatting chain and produces all its *Error values. Two concerns using serrors share no state unless they explicitly share a Domain instance.
  2. Structured errors: Error carries an Op string (the operation/operator/component name), an underlying Err, and an optional Data field for structured context. Data is invisible to Error() but fully accessible for logging and programmatic inspection.
  3. Sentinel hierarchy API (Sentinel, Derive, Detail, Detailf): replaces flat errors.New and fmt.Errorf wrappers for static package-level error variables, giving every node a parent it automatically matches via errors.Is.
  4. Three-layer formatting pipeline (FormatFunc, Delimiters, Formatter): controls how individual leaf errors render, how ops and labels are joined, and how the final string is assembled; each layer is independently customizable per domain.
  5. Opt-in stack traces via CaptureStackTrace(), passed as the data argument to WrapWith. No global flag; a trace is captured only where explicitly requested, stored as a typed StackTrace in Error.Data like any other context.
  6. Native slog integration: *Error implements slog.LogValuer. Passing an error to any slog call automatically emits its message and all structured Data as grouped attributes, no manual extraction needed.

Everything produced by serrors is compatible with the standard library: errors.Is, errors.As (and errors.AsType[T]), and errors.Unwrap all work as expected.

API Design Philosophy

serrors keeps its API surface small, built around a few orthogonal primitives rather than a wide family of convenience wrappers.

Many error libraries grow by layering With* helpers for every wrapping variation, which fragments the API and increases cognitive load. serrors instead provides Domain.Sentinel, Domain.Wrap, Domain.WrapWith, Error.Derive, Error.Detail, and domain-level configuration; primitives expressive enough to cover common use cases without narrowly scoped helpers for every variation.

This keeps the package lightweight:

  • Conceptually: fewer entry points, consistent mental model.
  • Practically: less API surface to learn, document, and maintain.
  • Operationally: no hidden behavior, no global switches, no implicit overhead.

When additional control is needed, it comes through explicit mechanisms (structured Data, domain-scoped formatters, and delimiter configuration) not by expanding the function set.

Compared to Existing Packages

serrors sits in the same space as github.com/pkg/errors, go.uber.org/multierr, github.com/cockroachdb/errors, and the Kubernetes error helpers, but consolidates their most useful ideas into a single domain-scoped model: hierarchical sentinels, structured data, opt-in stack traces, customizable formatting, and standard library interoperability.

Unlike those libraries, serrors is deliberately the lightest: no global switches, no mandatory stack-trace overhead, no hidden framework behavior. Errors are created and extended explicitly, no implicit annotation, automatic wrapping, or background propagation of context.

serrors does not replace the standard library or impose a new error model. It extends the existing one, remaining fully compatible with error, fmt.Errorf, errors.Is, and errors.As. It can be introduced incrementally: used where structure and inspection are needed, and set aside where they are not. Every *Error value produced by serrors:

  • Satisfies the error interface.
  • Works with errors.Is and errors.As* out of the box.
  • Chains correctly with errors.Unwrap.
  • Composes with errors.Join results.

Installation

go get github.com/MarwanAlsoltany/serrors

Examples

Runnable, self-verifying examples covering all major patterns are in example_test.go. They are compiled and executed on every go test ./... run and are rendered automatically on pkg.go.dev alongside the API documentation.

Quick Start

This package supports two modes of operation: package-level functions on a default domain (suitable for most small applications), and explicit Domain instances for library authors or multi-domain applications that need isolated error namespaces.

Example (package-level, using the default domain):

import (
    "database/sql"

    _ "github.com/lib/pq"
    "github.com/MarwanAlsoltany/serrors"
)

func openDB(dsn string) error {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        // wrap a low-level error with operation context
        return serrors.Wrap("database open", err)
    }
    return db.Ping()
}

The error message will be:

error: database open: dial tcp: connection refused

The label ("error") is the default. Can be freely changed to match some package:

Note: The default label "error" is deliberately non-idiomatic. It is meant to stand out and prompt replacement with a meaningful name.

func init() { serrors.SetDefault(serrors.New("my-app")) }
// or omitted entirely with serrors.New("")

Now the same error reads:

my-app: database open: dial tcp: connection refused

Sentinel Hierarchies

This is where serrors diverges most visibly from the standard library to provide a better experience for static sentinels.

With the standard library, sentinels are typically defined as var ErrX = errors.New("x"), which means they are flat and cannot express parent-child relationships.

A typical package declares its error tree once, at the package level, and everything else uses those sentinels. With serrors the whole tree is composed of *Error values (satisfying error interface), which means every node is inspectable and participates in errors.Is traversal automatically. Making it possible to catch not only operation failures but complete component/subsystem failure by matching on a root sentinel.

var domain = serrors.New("my-app")

// top-level sentinels: domain.Sentinel() creates an *Error with no parent
// which serves as a root for its subtree
var (
    ErrAuth     = domain.Sentinel("auth")
    ErrNetwork  = domain.Sentinel("network")
    ErrDatabase = domain.Sentinel("database")
)

// child sentinels: Err*.Derive() creates an *Error that wraps its parent (top-level sentinel)
var (
    ErrAuthExpired = ErrAuth.Derive("token expired")
    ErrAuthInvalid = ErrAuth.Derive("token invalid")

    ErrNetworkDial    = ErrNetwork.Derive("dial")
    ErrNetworkTLSHand = ErrNetwork.Derive("tls handshake")

    ErrDatabaseConnect = ErrDatabase.Derive("connect")
    ErrDatabaseTimeout = ErrDatabase.Derive("timeout")
    ErrDatabaseQuery   = ErrDatabase.Derive("query")
)

// leaf detail: .Detail() and .Detailf() wrap an *Error with a plain message string,
// useful when a specific error string is needed but not a named sentinel
return ErrNetworkDial.Detail("connection refused")
return ErrDatabaseConnect.Detailf("timed out after %ds", int(timeout/time.Second))

Every node in this tree automatically satisfies:

errors.Is(ErrDatabaseTimeout, ErrDatabase) // true
errors.Is(ErrDatabaseConnect, ErrDatabase) // true
errors.Is(ErrDatabaseQuery, ErrDatabase) // true
errors.Is(ErrDatabase, domain.Root()) // true

The full hierarchy is provided for free, with no need for custom Is methods or manual wrapping.

Derive vs. Detail/Detailf
Derive(op) Detail(msg) / Detailf(fmt, args...)
Return value *Error error
errors.Is inspectable yes yes
errors.As inspectable yes, Op field yes, parent *Error.Op only (detail text is plain string)
Wrapping with %w no Detailf only
Participates in formatters yes no (plain fmt.Errorf wrap)
Best used for named sentinels in error tree one-off detail on a leaf

The error return type for Detail* is intentional: the message does not belong on *Error.Op, which is structurally an operation name, not a message string. Detailf also delegates to fmt.Errorf internally, making *Error impossible to return without reimplementing %w semantics. See the Detail source doc comment for the full rationale.

Detailf supports %w, so any wrapped error remains reachable via errors.Is and errors.As:

err := ErrService.Detailf("query failed: %w", pkgErr)
// errors.Is(err, pkgErr) == true

The rule of thumb: if the error appears as a package-level var, use Error.Derive. For adding a runtime detail to an existing sentinel at the call-site, use Error.Detail*, or Domain.Wrap*.

Wrapping Runtime Errors

For errors that are not static sentinels but carry runtime context, use Wrap and Wrapf:

func (s *Store) QueryUser(id string) (*User, error) {
    row := s.db.QueryRow("SELECT * FROM users WHERE id = $1", id)
    if err := row.Scan(...); err != nil {
        // wrap the database error under the "query user" operation
        return nil, domain.Wrap("query user", err)
    }
    return user, nil
}

func (s *Store) CreateUser(u *User) error {
    if err := validate(u); err != nil {
        // formatted detail message
        return domain.Wrapf("create user", "validation failed for email %q: %w", u.Email, err)
    }
    // ...
}

Wrap also supports multiple errors in a single call (delegates to errors.Join internally):

var errs []error
for _, item := range batch {
    if err := process(item); err != nil {
        errs = append(errs, err)
    }
}
// returns nil if errs is empty or all-nil, no guard needed
return domain.Wrap("process batch", errs...)

The output for a multi-error result is a compact single-line (unlike the default multi-line errors.Join format):

The ":" and ";" delimiters are configurable via WithDelimiters.

my-app: process batch: item 2: invalid; item 5: not found; item 9: timed out
Attaching Structured Data

Use WrapWith and WrapWithf to attach arbitrary structured context to an error without embedding it in the error string. The data is stored in Error.Data and is retrievable programmatically, but is intentionally excluded from Error.Error(), keeping messages clean while still providing rich context for logging and inspection.

type ConnCtx struct {
    Host    string
    Attempt int
}

// optional: implement slog.LogValuer for automatic slog expansion
func (c ConnCtx) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("host", c.Host),
        slog.Int("attempt", c.Attempt),
    )
}

func dial(host string, attempt int) error {
    conn, err := net.Dial("tcp", host)
    if err != nil {
        return domain.WrapWith("dial", ConnCtx{host, attempt}, err)
    }
    return nil
}

Raw []slog.Attr slices are also accepted directly:

return domain.WrapWith("dial",
    []slog.Attr{slog.String("host", host), slog.Int("attempt", n)},
    err,
)

See the next section for how to retrieve and log the attached data.

Inspecting Errors

All standard library inspection tools work unchanged:

// check for a sentinel (works at any depth in the chain)
if errors.Is(err, ErrDatabase) {
    log.Warn("database error", "err", err)
}

// extract the operation name
var opErr *serrors.Error
if errors.As(err, &opErr) {
    metrics.Increment("errors", "op", opErr.Op)
}

// check for a concrete underlying type
if pkgErr := errors.AsType[*pkg.Error](err); pkgErr != nil {
    if pkgErr.Code == "123" {
        return ErrSomeError
    }
}

Because *Error implements Unwrap(), the entire standard library chain-walking machinery traverses it correctly and as one would expect.

serrors also provides a set of one-liner helpers that avoid the boilerplate of declaring a temporary variable and calling errors.As manually:

// extract the first *Error in the chain without a temporary variable
if e, ok := serrors.AsError(err); ok {
    metrics.Increment("errors", "op", e.Op)
    // e.Domain is also accessible, the Domain that produced the error
}

// first non-nil Data of the given type in the chain
ctx, ok := serrors.AnyDataAs[ConnCtx](err)

// all non-nil Data values of the given type in the chain, outermost first
ctxs := serrors.AllDataAs[any](err)

// walk the full chain
serrors.Walk(err, func(e *serrors.Error) bool {
    fmt.Println(e.Op, e.Data)
    return true
})

// check whether an error belongs to a specific domain
if domain.Contains(err) {
    // equivalent to errors.Is(err, domain.Root())
}
Accessing Structured Data

Error.Data is the structured context attached via WrapWith/WrapWithf. It is not part of the error string, so it must be retrieved programmatically.

Direct type assertion, when only one layer of context is expected:

var opErr *serrors.Error
if errors.As(err, &opErr) && opErr.Data != nil {
    if errCtx, ok := opErr.Data.(ConnCtx); ok {
        log.Printf("failed on host %q (attempt=%d)", errCtx.Host, errCtx.Attempt)
    }
}

AllDataAs: collects typed Data from every *Error node in the chain, outermost first. Useful when multiple wrapping layers each attach context of the same type:

for _, ctx := range serrors.AllDataAs[ConnCtx](err) {
    fmt.Printf("context: %+v\n", ctx)
}
// use serrors.AllDataAs[any](err) to collect all Data values regardless of type

LogAttrs: the slog-oriented counterpart to AllDataAs. Walks the chain and converts each Data value to []slog.Attr according to these rules:

  • []slog.Attr: returned verbatim.
  • slog.LogValuer: that resolves to a group, the group's attributes are inlined.
  • Any other type: wrapped as a single slog.Any(...) attribute under the key DataKey ("data").
slog.LogAttrs(ctx, slog.LevelError, "dial failed",
    append(serrors.LogAttrs(err), slog.String("err", err.Error()))...,
)
// -> level=ERROR msg="dial failed" host=db attempt=2 err="service: dial: connection refused"

*Error also implements slog.LogValuer directly, so passing it as an slog attribute value is idiomatic and produces the same structured output when Data is present; no explicit LogAttrs call needed:

// no Data anywhere in chain -> plain string value
slog.Error("dial failed", "err", wrappedErr)
// -> err="service: dial: connection refused"

// with Data -> slog group nested under the key
slog.Error("dial failed", "err", wrappedErr)
// -> err.message="service: dial: connection refused" err.host=db err.attempt=2

Stack Traces

Stack traces are opt-in by design. There is no global flag to activate; a trace is captured only where the consumer explicitly asks for it by passing CaptureStackTrace() as the data argument to WrapWith:

err := domain.WrapWith("op", serrors.CaptureStackTrace(), cause)

The returned StackTrace is stored in Error.Data like any other structured context. The standard inspection API retrieves it, 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)
    }
}
Depth/Skip Control for Helper Wrappers

When CaptureStackTrace is called inside a helper function rather than at the actual error site, the helper's frame appears at the top of the trace. Use CaptureStackTraceN(depth, skip) and pass 1 for each wrapper layer between the call site and CaptureStackTraceN:

func wrapWithTrace(op string, cause error) error {
    // skip=1 removes wrapWithTrace from the top of the stack trace
    return domain.WrapWith(op, serrors.CaptureStackTraceN(serrors.DefaultStackDepth, 1), cause)
}

For the common case (i.e. default depth, direct call) CaptureStackTrace() is sufficient (captures 32 frames).

Slog Integration

StackTrace implements slog.LogValuer as well. When it is stored as Error.Data, LogAttrs and Error.LogValue automatically expand it into a single semicolon-separated string attribute under the key StackTraceKey ("stack"):

slog.Error("request failed", "err", wrappedErr)
// -> err.message="service: op: cause" err.stack="github.com/user/app/store.go:42 main.openDB; github.com/user/app/main.go:17 main.run"

The single-line format is intentional, human-readable, searchable, and free of multi-line stack-dump noise.

Note: Build-environment prefixes are stripped from file paths: module cache -> module@version/file.go, main module -> module/file.go, stdlib -> package/file.go.

Domain Isolation

Each Domain has its own label and formatting chain, fully isolated from every other domain. Two packages that both depend on serrors share no state:

// in package A:
var domainA = serrors.New("package-a")

serrors.RegisterTypedFormatFunc(domainA, func(e *MyErrorA) string {
    return fmt.Sprintf("A: %s", e.Detail)
})

// in package B:
var domainB = serrors.New("package-b")

serrors.RegisterTypedFormatFunc(domainB, func(e *MyErrorB) string {
    return fmt.Sprintf("B: %s", e.Info)
})

Package A's format function never sees Package B's errors and vice versa. The application that imports both can add its own format functions to either domain or to the default one.

A Complete Library Setup
package library

import (
    "github.com/MarwanAlsoltany/serrors"
)

var domain = serrors.New("my-library")

// exported root for downstream errors.Is checks
var ErrLibrary = domain.Root()

// public error hierarchy
var (
    ErrConfig        = domain.Sentinel("config")
    ErrConfigMissing = ErrConfig.Derive("missing")
    ErrConfigInvalid = ErrConfig.Derive("invalid")

    ErrIO         = domain.Sentinel("io")
    ErrIORead     = ErrIO.Derive("read")
    ErrIOWrite    = ErrIO.Derive("write")
    ErrIONotFound = ErrIO.Derive("not found")
)

func init() {
    // register a format function for a third-party
    // error type specific to this library
    serrors.RegisterTypedFormatFunc(domain, func(e *os.PathError) string {
        return fmt.Sprintf("path %q: %s", e.Path, e.Err)
    })
}

Consumers of library can check:

errors.Is(err, library.ErrConfigMissing) // true; also matches ErrConfig and ErrLibrary
Copying and Extending Domains

Two methods create a new Domain derived from an existing one, plus one composable Option. They differ in whether a root link is established:

With(opts...) Sub(label, opts...)
Copies label + config yes yes
Applies new options yes yes
Root relationship independent (no is-a link) linked (is-a: child root wraps parent root)
errors.Is(childErr, parent.Root()) false true
Best used for snapshots, reconfigured sibling domain sub-component inheriting error identity

This allows for creating intricately structured domain hierarchies with shared or independent configuration as needed.

With

Returns a copy of the domain with its own independent root sentinel and any provided options applied. Changes to the child (formatters, etc.) do not affect the original. Calling With with no options is equivalent to a full clone.

// exact copy, independent root
copy := domain.With()

// peer with a different label but same formatting config
peer := domain.With(serrors.WithLabel("other-service"))
Sub

Creates a sub-domain (child) whose root sentinel wraps the parent's root, establishing an is-a relationship. Errors produced by the child automatically satisfy errors.Is(err, parent.Root()). The child inherits the parent's (base's) config (format functions, delimiters, formatter); its label is set by the required label argument, not inherited.

var main = serrors.New("main", serrors.WithFormatFunc(sharedFormatter))
var sub = main.Sub("sub-component")

err := sub.Wrap("op", errors.New("x"))
errors.Is(err, sub.Root())  // true
errors.Is(err, main.Root()) // true   <- is-a link
WithBase

The composable option counterpart to Sub. Use it with New or With when the label is set separately or when combining multiple options:

// same effect as main.Sub("sub-component")
sub := serrors.New("sub-component", serrors.WithBase(main))

// start fresh (no config inheritance) with an is-a link and custom delimiters
sub := serrors.New("sub-component", serrors.WithBase(main), serrors.WithDelimiters(custom))

Unlike Sub, WithBase does not set a label; the label is whatever the domain already has or is set explicitly by WithLabel.

Error Formatting

serrors produces flat error messages that are a blend of idiomatic Go style and opinionated single-line structure. The messages are friendly to structured log systems, easy to grep, and avoid the multi-line stack-trace noise that plagues some error libraries.

The formatting pipeline has three independent customization points, each operating at a different layer:

Layer API Controls
Leaf text FormatFunc (via WithFormatFunc, RegisterFormatFunc, RegisterTypedFormatFunc) How individual non-*Error errors (i.e. non-serrors errors) are rendered into text (i.e. final error string)
Delimiters Delimiters (via WithDelimiters, With*Delimiter) The delimiter strings that join label, ops, tail, and joined errors
Full assembly Formatter (via WithFormatter) The complete output; receives raw structured data and returns the final error string

Not every application needs all three layers. Start with the narrowest tool and only reach for the next when the previous is insufficient:

  1. FormatFunc is the right choice in most cases. It controls how individual non-*Error error types render their text without affecting message structure. This is all most applications ever need.
  2. Delimiters is the right choice when only the separator convention needs to change. It requires no function and has no runtime cost.
  3. Formatter is a last resort. Use it only when the delimiter-based assembly cannot produce the required format; for example, when output must follow an external schema.

To understand why both FormatFunc and Formatter exist, it helps to know how the default format is assembled. The formatter walks the *Error chain collecting ops, then reaches the innermost non-*Error error i.e. the leaf. The leaf is formatted into a string called the tail. The final output is the label, followed by the op chain, followed by the tail, each joined by delimiters:

<label><delimiter.label><op1><delimiter.part><opN>...<delimiter.part><tail>
Default Format

The default formatter assembles messages in this shape:

<label>: [<ancestor-op>: ...] <op>: <underlying>

When the domain label is empty, the label and its delimiter are omitted entirely:

[<ancestor-op>: ...] <op>: <underlying>

This mirrors how an empty Op is silently omitted from the chain. An empty label is valid and can be set via New("") or WithLabel("") when no prefix is desired.

The three delimiter strings used are:

Delimiter Default Role
Label ": " Between the domain label and the first op or message
Part ": " Between individual ops and between the last op and the tail
Join "; " Between children of a multi-error (errors.Join) aggregate

For a chain like ErrConfigMissing above:

library: config: missing

For a wrapped runtime error under ErrIORead:

library: io: read: open /etc/app.conf: no such file or directory

For multiple aggregated errors:

library: io: read: open /etc/a.conf: no such file or directory; open /etc/b.conf: permission denied
Format Functions

Format functions are consulted in reverse registration order; the last registered format function to return ok=true wins (last-match semantics), this allows overriding previous format functions. If no format function matches — including for foreign types (fmt.Errorf, errors.New, third-party errors) that may appear anywhere in the chain — the error's own Error() method is used as a fallback.

Three ways to register a FormatFunc are available, each suited to a different scenario:

At Construction Time (WithFormatFunc)

Usage: When the format function is known at domain setup time and should be part of the domain's permanent configuration. Construction-time format functions are inherited by Sub and With child domains and cannot be removed after the domain is created. Call WithFormatFunc multiple times to register multiple format functions.

domain := serrors.New("app",
    serrors.WithFormatFunc(func(err error) (string, bool) {
        var pkgErr *pkg.Error
        if !errors.As(err, &pkgErr) {
            return "", false
        }
        return fmt.Sprintf("pkg(%s): %s", pkgErr.Code, pkgErr.Message), true
    }),
)
// app: query: pkg(123): something went wrong
At Runtime - Manual (RegisterFormatFunc)

Usage: When the format function needs to be registered or unregistered dynamically after the domain is created, or when a single function handles multiple error types. Returns an unregister function so the format function can be unregistered cleanly (e.g. in tests).

unregister := domain.RegisterFormatFunc(func(err error) (string, bool) {
    pkgErr, ok := err.(*pkg.Error)
    if !ok {
        return "", false
    }
    return fmt.Sprintf("pkg-%s: %s", pkgErr.Code, pkgErr.Message), true
})
defer unregister()

Note: RegisterFormatFunc can be used to register a catch-all format function that handles multiple types in one place, but it requires manual type assertions and nil checks.

At Runtime - Type-Safe (RegisterTypedFormatFunc)

Usage: When only a single concrete error type needs a format function. The generic wrapper handles the type assertion and nil check automatically. The nil check matters here: in Go, a *pkg.Error nil stored in an error interface is not nil at the interface level, so a naive format function could panic. serrors detects and skips typed nils before calling the format function.

unregister := serrors.RegisterTypedFormatFunc(domain, func(err *pkg.Error) string {
    return fmt.Sprintf("pkg: %s (code=%s)", err.Message, err.Code)
})
defer unregister()
Customizing Delimiters

Usage: When the default ": " / "; " delimiters clash with surrounding conventions. For example, a log pipeline that already uses ": " as a field delimiter, or a display format that prefers arrow-separated chains.

Call WithDelimiters to replace all three delimiters at once:

domain := serrors.New("service", serrors.WithDelimiters(serrors.Delimiters{
    Label: " | ",
    Part:  " > ",
    Join:  " & ",
}))
// service | op1 > op2 > underlying
// service | op > err a & err b & err c   (multi-error)

Individual helpers are also available when only one field needs to change:

domain := serrors.New(
    "service",
    serrors.WithLabelDelimiter(" | "),
    serrors.WithPartDelimiter(" > "),
    serrors.WithJoinDelimiter(" & "),
)
Custom Formatter

Usage: When the delimiter-based assembly model is not expressive enough. For example, when the output format must follow an external schema, or when ops should be encoded differently from the tail (e.g. as a path prefix rather than a colon-separated chain).

Implement the Formatter interface (or use the FormatterFunc adapter) to take full control of the final string. The Format method receives the leaf error and a FormatSpec containing format specification data:

  • spec.Label: The domain label string.
  • spec.Ops: The ordered ops slice, ancestor-first.
  • spec.Delimiters: The domain's configured delimiters.
  • spec.Apply(err): Formats any error through the domain's FormatFunc chain, applying last-match semantics and falling back to err.Error(). Use this to format both the leaf error and any children of an errors.Join aggregate.
domain := serrors.New("app",
    serrors.WithFormatter(serrors.FormatterFunc(func(err error, spec serrors.FormatSpec) string {
        if len(spec.Ops) == 0 {
            return fmt.Sprintf("[%s] %s", spec.Label, spec.Apply(err))
        }
        return fmt.Sprintf("[%s/%s] %s", spec.Label, strings.Join(spec.Ops, "/"), spec.Apply(err))
    })))
// [app/op1/op2] underlying error message

Use DefaultFormatter() as a base when you only want to wrap or post-process the output rather than replacing it entirely:

base := serrors.DefaultFormatter()
domain := serrors.New("app",
    serrors.WithFormatter(serrors.FormatterFunc(func(err error, spec serrors.FormatSpec) string {
        return "[" + base.Format(err, spec) + "]"
    })))
// [app: op: underlying]
Using All Three Together

For the most control and to produce any output format, combine a FormatFunc, Delimiters, and a full Formatter. Each controls a distinct layer:

domain := serrors.New("app",
    // 1. leaf text: custom format for a third-party error type
    serrors.WithFormatFunc(func(err error) (string, bool) {
        var pkgErr *pkg.Error
        if !errors.As(err, &pkgErr) {
            return "", false
        }
        return fmt.Sprintf("pkg(%s): %s", pkgErr.Code, pkgErr.Message), true
    }),
    // 2. delimiters: non-default delimiters (applied inside spec.Apply(err))
    serrors.WithDelimiters(serrors.Delimiters{Label: " | ", Part: " > ", Join: " & "}),
    // 3. full assembly: custom top-level structure
    //    spec.Apply(err) returns "pkg(123): ..." with " & " for multi-errors
    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))
    })),
)
// [app] op1/op2: pkg(123): something went wrong

Concurrency

All Domain operations are safe for concurrent use with the following guidance:

  • Error formatting (err.Error(), e.LogValue()) is always safe, with no restrictions.
  • Register*FormatFunc is safe for concurrent use, including concurrent calls on the same domain. Calling it once at program startup (typically in init()) is conventional but not required.
  • Domain's Sentinel/Wrap*/WrapWith*, and Error's Derive/Detail*; are safe to call from any goroutine at any time.

API Reference

Package-Level (default domain)
// the default domain instance for package-level functions
Default() *Domain
// replace the default domain with a custom one
SetDefault(d *Domain)
// replace the default domain with a fresh stock domain (for tests only)
Reset()
// create a new named sentinel on the default domain
Sentinel(op string) *Error
// wrap one or more errors with an operation context
Wrap(op string, errs ...error) error
// wrap with formatted detail
Wrapf(op string, format string, args ...any) error
// wrap with structured data
WrapWith(op string, data any, errs ...error) error
// wrap with structured data and formatted detail
WrapWithf(op string, data any, format string, args ...any) error
// register a format function for the default domain
// see also RegisterTypedFormatFunc for a type-safe alternative
RegisterFormatFunc(fn FormatFunc) func()
Domain Methods
// create a new error domain with the given label and optional options
serrors.New(label string, opts ...Option) *Domain

// the root sentinel for this domain, parent of all sentinels of the domain
d.Root() *Error
// create a new sentinel error with the given operation name
d.Sentinel(op string) *Error
// get the label of this domain
d.Label() string
// reports whether err belongs to this domain (true when errors.Is(err, d.Root()))
d.Contains(err error) bool
// wrap one or more errors with an operation context
d.Wrap(op string, errs ...error) error
// wrap with formatted detail
d.Wrapf(op string, format string, args ...any) error
// wrap with structured data
d.WrapWith(op string, data any, errs ...error) error
// wrap with structured data and formatted detail
d.WrapWithf(op string, data any, format string, args ...any) error
// register a FormatFunc for this domain; returns an unregister function
d.RegisterFormatFunc(fn FormatFunc) func()
// create a new domain by copying the receiver and applying opts; independent root (no is-a link)
d.With(opts ...Option) *Domain
// create a sub domain with a new label that inherits the parent's root sentinel hierarchy (is-a link)
d.Sub(label string, opts ...Option) *Domain
Domain Options
// set the domain label (useful in With to rename without an is-a link)
WithLabel(label string) Option
// establish an is-a link to base: errors from this domain satisfy errors.Is against base.Root()
WithBase(base *Domain) Option
// set all three delimiter strings at once
WithDelimiters(d Delimiters) Option
// set only the label delimiter (between domain label and first op/tail)
WithLabelDelimiter(delim string) Option
// set only the part delimiter (between ops and tail in the default assembly)
WithPartDelimiter(delim string) Option
// set only the join delimiter (between errors.Join children)
WithJoinDelimiter(delim string) Option
// append a FormatFunc to the chain at construction time; can be called multiple times
WithFormatFunc(fn FormatFunc) Option
// set a full-control Formatter (replaces default delimiter-based assembly)
// use FormatterFunc to wrap a plain function
WithFormatter(f Formatter) Option
Error Type and Methods

Type:

type Error struct {
    Op     string   // operation/operator/component name
    Err    error    // underlying error
    Data   any      // optional structured context attached via WrapWith/WrapWithf; nil for sentinels and plain Wrap calls
    Domain *Domain  // domain this error belongs to; nil uses Default() at formatting/Is time (nil-means-default)
}

Methods:

// typed child sentinel, same domain
e.Derive(op string) *Error
// plain leaf with literal message
e.Detail(msg string) error
// plain leaf with formatted message; supports %w for inner error wrapping
e.Detailf(format string, args ...any) error
// implements error interface (Data is not included in the string representation)
e.Error() string
// single-error unwrap
e.Unwrap() error
// matches root sentinel of domain (and ancestor roots for Sub)
e.Is(target error) bool
// implements slog.LogValuer; returns a plain string when no Data is present,
// or a slog group (message + all Data attrs) when at least one node carries Data
e.LogValue() slog.Value
Standalone Functions
// top-level generic function for registering a typed format function on a specific domain
RegisterTypedFormatFunc[T error](d *Domain, fn func(T) string) func()

// walk chain calling fn for each *Error node, outermost first; return false from fn to stop early
Walk(err error, fn func(*Error) bool)
// extract the first *Error from the chain; ok=false if none found
AsError(err error) (*Error, bool)
// first Data value in the chain assignable to T; ok=false if none found
AnyDataAs[T any](err error) (T, bool)
// walk chain, collect Data values assignable to T, outermost first
AllDataAs[T any](err error) []T

// walk chain, collect Data as slog attrs, outermost first
LogAttrs(err error) []slog.Attr

// capture the current goroutine's call stack, up to DefaultStackDepth frames
CaptureStackTrace() StackTrace
// like CaptureStackTrace but with explicit frame limit and skip count
CaptureStackTraceN(depth, skip int) StackTrace

// return the built-in Formatter; useful as a base for wrapping the default output
DefaultFormatter() Formatter
// format err using d's pipeline; useful for testing custom formatting configurations
FormatError(d *Domain, err error) string
Types
// StackTrace holds raw program counters captured at a call site
type StackTrace []uintptr
// resolve the raw program counters to human-readable runtime.Frame values
st.Frames() []runtime.Frame
// implements slog.LogValuer; emits all frames as a semicolon-separated string
// under StackTraceKey ("stack") in a slog group
st.LogValue() slog.Value

// FormatFunc formats a single error to a compact string representation;
// return ("", false) when the format function does not handle the given type
type FormatFunc func(error) (s string, ok bool)

// Formatter is the full-control interface for assembling an error's string representation
type Formatter interface {
    Format(err error, spec FormatSpec) string
}
// FormatterFunc adapts a plain function to the Formatter interface
type FormatterFunc func(err error, spec FormatSpec) string

// FormatSpec carries the formatting pipeline for a domain snapshot
type FormatSpec struct {
    Label      string       // domain label, e.g. "my-app"; can be empty
    Ops        []string     // operation chain, ancestor-first
    Delimiters Delimiters   // the domain's configured delimiter strings
}

// Apply formats err through the domain's FormatFunc chain; falls back to err.Error()
(spec FormatSpec).Apply(err error) string

// Delimiters controls the three delimiter strings used when formatting errors
type Delimiters struct {
    Label string // between domain label and first op/tail; default ": "
    Part  string // between individual ops and between last op and tail; default ": "
    Join  string // between children of an errors.Join aggregate; default "; "
}
Constants
DefaultStackDepth = 32        // default frame limit used by CaptureStackTrace
DataKey           = "data"    // slog attribute key used when Data is wrapped as a fallback slog.Any attribute
MessageKey        = "message" // slog attribute key for the error message in Error.LogValue groups
StackTraceKey     = "stack"   // slog attribute key for the stack trace in StackTrace.LogValue groups

License

See LICENSE.

Documentation

Overview

Package serrors provides structured error handling with customizable formatting and error chaining.

This package supports two modes of operation:

  1. Package-level functions that operate on a default domain (suitable for applications and simple use cases).
  2. 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

Examples

Constants

View Source
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"
)
View Source
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

func AllDataAs[T any](err error) []T

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

func AnyDataAs[T any](err error) (T, bool)

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

func FormatError(d *Domain, err error) string

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

func LogAttrs(err error) []slog.Attr

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

func RegisterTypedFormatFunc[T error](d *Domain, fn func(T) string) func()

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

func Walk(err error, fn func(*Error) bool)

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

func Wrap(op string, errs ...error) error

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

func WrapWith(op string, data any, errs ...error) error

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

func WrapWithf(op string, data any, format string, args ...any) error

WrapWithf calls Domain.WrapWithf on the default domain. See Domain.WrapWithf for the semantics and conventions of data.

func Wrapf

func Wrapf(op string, format string, args ...any) error

Wrapf calls Wrapf on the default domain. This is equivalent to Wrap(op, fmt.Errorf(format, args...)).

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

func New(label string, opts ...Option) *Domain

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

func (d *Domain) Contains(err error) bool

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) Label

func (d *Domain) Label() string

Label returns the current label for this domain.

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

func (d *Domain) Root() *Error

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

func (d *Domain) Sentinel(op string) *Error

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

func (d *Domain) Sub(label string, opts ...Option) *Domain

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

func (d *Domain) With(opts ...Option) *Domain

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

func (d *Domain) Wrap(op string, errs ...error) error

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

func (d *Domain) WrapWith(op string, data any, errs ...error) error

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

func (d *Domain) WrapWithf(op string, data any, format string, args ...any) error

WrapWithf is like Domain.Wrapf but attaches arbitrary structured data to the error. See Domain.WrapWith for the semantics and conventions of data.

func (*Domain) Wrapf

func (d *Domain) Wrapf(op string, format string, args ...any) error

Wrapf creates a formatted error and wraps it. It returns an error whose concrete type is *Error, bound to this domain. This is equivalent to Wrap(op, fmt.Errorf(format, args...)).

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:

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

func AsError(err error) (*Error, bool)

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 Sentinel

func Sentinel(op string) *Error

Sentinel calls Sentinel on the default domain.

func (*Error) Derive

func (e *Error) Derive(op string) *Error

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

func (e *Error) Detail(msg string) error

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

func (e *Error) Detailf(format string, args ...any) error

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:

  1. 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.
  2. 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) Error

func (e *Error) Error() string

Error implements the error interface. See the Error type for the format.

func (*Error) Is

func (e *Error) Is(target error) bool

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

func (e *Error) LogValue() slog.Value

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

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying error (single unwrap).

type FormatFunc

type FormatFunc func(error) (s string, ok bool)

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

func WithBase(base *Domain) Option

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

func WithFormatter(f Formatter) Option

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

func WithJoinDelimiter(delim string) Option

WithJoinDelimiter sets Delimiters.Join for the domain. See Delimiters for the role of the join delimiter inside errors.Join formatting.

func WithLabel

func WithLabel(label string) Option

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

func WithLabelDelimiter(delim string) Option

WithLabelDelimiter sets Delimiters.Label for the domain. See Delimiters.

func WithPartDelimiter

func WithPartDelimiter(delim string) Option

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL