Skip to content

The introspection contract

Everything this package does to an error after it exists — collect hints, find a stack, render %+v, build a log group — goes through one traversal and two interfaces. That is a deliberate constraint, and it is what makes the rest possible.

One traversal

There is a single chain walker in the package. Every reader uses it. It handles Unwrap() error and Unwrap() []error alike, outermost first, depth-first through the members of an aggregate.

Having exactly one is the point. The library this replaces has the traversal written out at each reader, and they do not agree: hints, details, context tags and telemetry keys each walk single-unwraps only, so all four silently stop at an aggregate. A bug like that cannot be fixed once — it has to be found four times.

Two interfaces

type Kinder interface {
    ErrorKind() string
}

type Payloader interface {
    ErrorKind() string
    ErrorPayload() any
}

ErrorKind is a stable identity for a layer: errors.hint, errors.stack, or whatever a sentinel was declared with. It is what routes a layer — to a wire codec, to a telemetry attribute. Treat it as a serialisation key, because that is what it will be.

ErrorPayload is what the layer carries, for anything that needs to move or record it rather than print it.

KindOf reads these to answer "what kind of error is this", skipping the package's own annotation layers so it returns forge.provider_not_found rather than errors.attrs. An annotation says how an error was decorated; only a sentinel or a leaf says what it is.

Stacks have a third, StackTracer, because a stack is a capability several kinds have rather than a kind of its own — a leaf from New carries one, and so does a WithStack wrapper, but they are different kinds with different payloads.

The rule that matters

Nothing inside this package type-switches on its own concrete types.

That sounds like style. It is the load-bearing decision, and here is why.

A wire codec — arriving later as a sibling module — has to handle receiving a kind it has no decoder for. The right behaviour is to produce a layer that preserves the message, its position in the chain, and everything below it, so the error survives the trip even where the receiving side is older than the sender.

That is only possible if every reader treats an unrecognised layer as transparent rather than terminal. A reader that switches on known concrete types would stop dead at a stranger, and hints below it would disappear — exactly the failure this package exists to avoid, reintroduced by a different route.

So the property is tested directly: a wrapper type the package has never seen is put in the middle of a chain, and hint collection, attribute collection, Is and stack lookup all still work through it.

The same property is what lets a consumer's own error type participate. Implement ErrorKind and your layer is routable; implement StackTracer and your stack is findable. Nothing needs to be registered here.

What this buys, concretely

  • Wire serialization can be added without touching the core.
  • go/observability can render an error onto a span reading only these interfaces, so telemetry needs no privileged access and this package needs no knowledge of OpenTelemetry.
  • slog.LogValuer is built from the same reads, so a hint reaches a log record and a span attribute by one route rather than two.

Three consumers, one contract. If a fourth arrives wanting something the contract cannot express, that is the signal to change the contract — not to add a second way of reading an error.