Skip to content

MarwanAlsoltany/serrors

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

serrors - Structured Errors for Go

CI Go Reference Go Report Card Coverage License: Apache 2.0

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.

About

Structured errors for Go: sentinel hierarchies, typed data, custom formatting, and slog integration.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors