skip to content

How does a value versus pointer receiver on Error() string change which errors.As target matches?

level: middleimportance: nice to knowfreq 38%

answer

  1. method sets decide who is an error
  2. matching is by assignability, not satisfaction
  3. value receiver means two possible forms
  4. the target must mirror what was returned
  5. guessing wrong returns false, not a panic

basics

~20 s

A pointer receiver puts Error only in the method set of *T, so the package can only return *T and callers must target a *T variable. A value receiver lets both T and *T be errors, so the target must match whichever form the package actually returns.

solid answer

~40 s

`errors.As` matches on assignability of the error's concrete dynamic type to the type the target points at, not on interface satisfaction. So the receiver decides the whole shape. With `func (e *ParseError) Error() string`, only `*ParseError` implements `error`; the package can only ever return `&ParseError{...}`, and the caller writes `var pe *ParseError; errors.As(err, &pe)`. With a value receiver, both `ParseError` and `*ParseError` implement `error`, so the package could return either — and a caller targeting `*ParseError` silently gets `false` when the package returned a value, and vice versa. That is why the convention is a pointer receiver plus always returning a pointer: it collapses the ambiguity to one form callers cannot get wrong.

code

go · 17 lines
go
type ParseError struct{ Line int }

func (e ParseError) Error() string {
	return fmt.Sprintf("parse failed at line %d", e.Line)
}

func report(err error) {
	var pv ParseError
	if errors.As(err, &pv) { // matches only a returned ParseError value
		fmt.Println("value form, line", pv.Line)
	}

	var pp *ParseError
	if errors.As(err, &pp) { // matches only a returned *ParseError
		fmt.Println("pointer form, line", pp.Line)
	}
}

go deeper

for a junior

Know the mechanical rule: look at what the function returns, declare a variable of exactly that type, and pass its address. If the package returns &T{}, your variable is a *T.

for a middle

Explain both halves — which method set a value receiver versus a pointer receiver produces, and that errors.As matches by assignability of the dynamic type — and connect them to why one target silently fails.

for a senior

Point out that a mismatched target fails silently rather than loudly, and describe how you would catch it: a test that drives a real failure through the exported function and asserts the typed branch fires.

for a principal

Own the consistency rule across a codebase: one receiver convention for error types, enforced by generation or review, so no caller has to read docs to know whether a package returns a value or a pointer.

## Two rules meeting This question sits at the intersection of two independent Go rules, and it is confusing only until you separate them. **Rule one — method sets.** For a type `T` with a method declared on a value receiver, both `T` and `*T` have that method. For a method declared on a pointer receiver, only `*T` has it. So: | Receiver on `Error()` | Implements `error` | |---|---| | `func (e T) Error() string` | `T` and `*T` | | `func (e *T) Error() string` | `*T` only | **Rule two — how `errors.As` matches.** It does *not* ask "does this error satisfy the target's interface". It asks whether the concrete dynamic type stored in the error is **assignable** to the type the target points at. `*ParseError` is not assignable to `ParseError`, and `ParseError` is not assignable to `*ParseError`. They are different types, and the search simply does not match across them. ## What that means at the call site The caller must declare a variable of exactly the type the package returns, then pass its address: * Package returns `&ParseError{...}` (dynamic type `*ParseError`) → caller writes `var pe *ParseError` and passes `&pe`. * Package returns `ParseError{...}` (dynamic type `ParseError`) → caller writes `var pe ParseError` and passes `&pe`. Get it wrong and nothing crashes: both targets are legal (both `ParseError` and `*ParseError` implement `error` when the receiver is a value), so `errors.As` dutifully searches, finds nothing assignable, and returns `false`. The branch that should have produced a precise 422 with a line number quietly produces a generic 500 instead. A silent `false` is far more expensive to debug than a panic. ## Why the pointer receiver is the convention Declaring `Error()` on the pointer receiver removes the choice. `ParseError` no longer implements `error`, so the compiler rejects `return ParseError{...}` outright with a message noting that the method has a pointer receiver. There is exactly one dynamic type any caller can ever observe, and exactly one target shape that works. Secondary benefits: the struct is not copied every time something formats it, and the type can grow a field that must not be copied without the decision being revisited. A value receiver is not wrong in itself — some very small, deliberately copyable error types use one — but if you choose it, you take on the duty of returning one form consistently and documenting it, because the compiler will no longer enforce it for you. ## Generated code makes the choice once This is a strong argument for generating error types rather than hand-writing one per package. A generator that emits Go types from a schema emits the receiver, the constructor and the documented target shape together, so every generated package agrees. A codebase where half the packages return `MyErr` and half return `*MyErr` forces every caller to check the docs before writing an `errors.As`, and the failure mode for guessing wrong is invisible. ## The mirror-image mistake With a pointer-receiver type there is a second level of indirection that trips people up. The value you want is a `*ParseError`, so the *variable* is a `*ParseError` and the *target* is `&pe`, a `**ParseError`. Declaring `var pe ParseError` and passing `&pe` looks right and compiles — `ParseError` does not implement `error`, so this one actually panics rather than returning false, which is at least loud. Declaring the variable at the right level and passing its address is the habit to build. ## Quick check If you can answer "what is the dynamic type of the error this function returns", you can write the target mechanically: take that type, declare a variable of it, pass the address. Everything else about receivers follows from there.

  • If Error() has a value receiver, can a caller still target *ParseError?
    Only if the package actually returned a pointer. Both forms implement `error`, so both targets compile and both searches run; the one that does not match the returned dynamic type simply reports false. That ambiguity is the cost of the value receiver, and the reason it has to be documented.
  • Does errors.As match on interface satisfaction or on assignability?
    On assignability of the error's concrete dynamic type to the type the target points at — except when the target points at an interface type, in which case satisfying that interface is the test. `*ParseError` and `ParseError` are distinct types, so neither matches a target declared for the other.
  • What does the compiler say if you return a value while Error() is on the pointer receiver?
    It rejects the return, reporting that the type does not implement `error` because method `Error` has a pointer receiver. That is the main practical benefit of the pointer receiver: the mistake becomes a build failure instead of a runtime search that silently finds nothing.

saying these in an interview costs you the question

  • Assumes errors.As matches T and *T interchangeably
  • Says a mismatched target panics rather than returning false
  • Declares var pe ParseError when the package returns *ParseError
  • Thinks the receiver choice only affects performance
  • Believes errors.As tests interface satisfaction for concrete targets