Skip to content

Annotations

The six functions that attach something to an existing error. All of them return nil when given nil, and none of them change the error's message.

Which one to reach for

You want to record Use Who reads it
what the user should do WithHint a person, in CLI output
operator-facing prose WithDetail a person, in verbose output
something you will query WithAttrs a log query, a trace span
where it surfaced WithStack whoever is debugging

The hint/detail split earns its keep: "configuration uses keys that are no longer read" is the message, and "move it to codeberg.auth.value" is the hint — the half a user can act on.

For anything a machine will query, reach for WithAttrs rather than WithDetail. A detail is prose; an attribute is a key and a value.

WithHint and WithHintf

func WithHint(err error, hint string) error
func WithHintf(err error, format string, args ...any) error

Attaches user-facing remediation. The message is untouched:

err := errors.WithHint(errors.New("not configured"), "Run `mytool init`.")

fmt.Println(err)              // not configured
errors.Hints(err)             // [Run `mytool init`.]

Multiple hints accumulate. Hints returns them outermost-first and de-duplicated.

WithDetail and WithDetailf

func WithDetail(err error, detail string) error
func WithDetailf(err error, format string, args ...any) error

Attaches operator-facing prose, surfaced in %+v output but not in the message. Same accumulation and de-duplication rules as hints.

WithAttrs

func WithAttrs(err error, attrs ...slog.Attr) error

Attaches structured key/value context.

return errors.WithAttrs(err, slog.String("host", host), slog.Int("attempt", n))

slog.Attr is used rather than a bespoke type because both consumers want exactly that shape — a log record and an OTEL span attribute are both key/value, and log/slog is standard library, so it costs no dependency.

Two behaviours worth knowing:

Calling it with no attributes returns the error unchanged, not nil:

base := errors.New("base")
errors.WithAttrs(base) == base   // true

Returning nil there would silently discard a real error at a call site that reads as harmless.

Duplicate keys are kept, outermost first. An outer layer adding context an inner one already recorded is information, not noise, and which one wins is the caller's decision:

err := errors.WithAttrs(
    errors.WithAttrs(errors.New("x"), slog.String("host", "inner")),
    slog.String("host", "outer"),
)
errors.Attrs(err)   // [host=outer host=inner]

There is deliberately no mutable map handed back to the caller. Mutating an error after construction invites aliasing and races.

WithStack

func WithStack(err error) error

Attaches a call stack captured at the call site — unless the tree already carries one, in which case the error is returned unchanged:

e := errors.New("has a stack already")
errors.WithStack(e) == e   // true

This is how a sentinel becomes a returnable error with a useful stack. The stack is captured where the error is returned rather than where it was declared:

var ErrNotFound = errors.NewSentinel("forge.not_found", "provider not found")

func load(name string) error {
    return errors.WithStack(ErrNotFound)   // stack points here
}

Captured stacks are bounded at 32 frames.