skip to content

Why should an exported Go function declare its result as error rather than *ValidationError?

level: middleimportance: should knowfreq 58%

answer

  1. the signature is the promise callers compile against
  2. one word wider than the type you build
  3. an empty box still has a label
  4. callers write err != nil and it fires on success

basics

~20 s

Declare the result as error, the interface type. A signature returning a concrete pointer such as *ValidationError hands callers a value that is not equal to nil even when the pointer is nil, so successful calls trip their error check.

solid answer

~50 s

The rule is that an exported function's failure result is declared `error`, not a concrete type. The immediate reason is a trap: when a caller assigns a nil `*ValidationError` into an `error` variable, the interface value records the concrete type, so `err != nil` is true and a successful call is reported as a failure. Every caller of that package has to know the quirk, and the ones that wrap or forward the value will get it wrong. The second reason is contractual: `error` promises only "this can fail", so you keep the freedom to return a sentinel, a wrapped error, or a different type later without changing the signature, while callers that need the details use `errors.As` to reach for the concrete type. Return the concrete type only from unexported helpers, and convert to `error` at the package boundary.

code

go · 20 lines
go
type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return "invalid field " + e.Field }

// Bad: the signature pins the failure to one concrete type, and a caller
// who stores the nil result in an error variable sees a non-nil error.
func CheckBad(env string) *ValidationError {
	if env == "" {
		return &ValidationError{Field: "env"}
	}
	return nil
}

// Good: the signature promises only that the call can fail.
func Check(env string) error {
	if env == "" {
		return &ValidationError{Field: "env"}
	}
	return nil
}

go deeper

for a junior

Remember the rule itself: a function that can fail returns error, and the success path returns a plain nil. You are not expected to explain interface internals, only to write the signature correctly.

for a middle

Explain the mechanism: an interface value carries a type as well as a value, so a nil concrete pointer stored in an error is not nil, and a caller's standard check fires on success. Show how errors.As preserves the caller's access to details.

for a senior

Bring the caller's perspective: you are shipping a signature other teams compile against, and this one silently reports success as failure. Be able to say where concrete error types are still fine, and how you document what callers may match on.

for a principal

Frame it as a boundary policy: what a package promises about its failures, how much detail is exported for matching, and the cost of ever widening a result type once other teams depend on it.

## The rule An exported Go function that can fail declares its last result as the built-in interface type `error`: ``` func Check(env string) error ``` not as the concrete type it happens to construct today: ``` func Check(env string) *ValidationError // don't ``` This holds even when the function can only ever fail one way, and even when the concrete type is exported and documented. ## The trap that makes it a hard rule `error` is an interface: one method, `Error() string`. An interface value is a pair — a dynamic type and a value — and it is equal to `nil` only when both halves are unset. Assigning a nil `*ValidationError` into an `error` fills in the type half, so the resulting interface value is **not** nil. That is fatal at an API boundary, because callers do not read your body; they read your signature and write the universal check: ``` if err := pkg.Check(env); err != nil { return err } ``` If `Check` is declared to return `*ValidationError`, the caller who stores that result in an `error` variable — which is what happens the moment they forward it, wrap it, put it in a slice, or return it from their own function — sees a non-nil error on a perfectly successful call. The failure is silent, it is on the *happy* path, and it usually surfaces far away from the package that caused it. Declaring the result as `error` removes the possibility: the function returns a literal `nil` on success, the interface is genuinely empty, and the caller's check works. ## The contractual reason Even without the trap, a concrete result type over-promises. The signature is the promise other teams compile against: - **`error` says "this call can fail" and nothing more.** You may later return a sentinel value, an error built by `fmt.Errorf`, a joined set of errors, or a different struct entirely, and no caller's code changes. - **A concrete type says "failures are always exactly this".** Widening it later means editing the signature, which is an incompatible change for everyone who wrote `var e *ValidationError = pkg.Check(...)`. - **Callers who need the detail still have it.** `errors.As` will find a `*ValidationError` anywhere in the chain, and a documented, exported error type plus `errors.As` in the doc comment is the idiomatic way to offer detail without pinning the signature. ## Where the concrete type is fine Inside the package. An unexported helper may return `*ValidationError` so the code that builds and inspects it stays typed; the exported wrapper converts to `error` at the boundary. The dangerous move is the one that looks harmless — returning that helper's result directly from a function declared to return `error` while the helper's own signature is concrete, because the nil case is exactly where the typed value slips into the interface. The safe shape is an explicit `return nil` on the success path of the exported function. ## What this looks like in review Three things a reviewer checks on any exported signature: 1. The failure result is spelled `error`. 2. The success path returns a literal `nil`, not a variable of a concrete pointer type. 3. If callers need to distinguish failures, the doc comment says which exported error types or sentinel values they can look for, so nobody has to string-match the message. ## Why interviewers ask It is a single-line signature decision whose consequences land in someone else's codebase, which makes it a good probe for whether a candidate designs for callers rather than for the function body. A candidate who says "return `error` because interfaces are more flexible" has half of it; the other half — that a concrete pointer result can make a caller's `err != nil` fire on success — is the part that turns a preference into a rule.

  • If the result is declared error, how does a caller still get at the Field of a *ValidationError?
    With `errors.As`, which walks the chain and, on a match, fills in a `*ValidationError` variable the caller declared. The exported type and the fact that callers may look for it belong in the doc comment; that way detail is available without the signature committing to one failure type forever.
  • Does the same rule apply to a function's non-error results — should they be interfaces too?
    No. The error position is special because `error` is a universal interface every caller already handles. Ordinary results are best returned as the concrete type, so callers keep the methods and fields without a type assertion. "Widen the failure, keep the value concrete" is the usual shape.
  • Is it enough to just remember to return a literal nil on the success path?
    It works, but it relies on every present and future maintainer remembering, and it does not help the caller who assigns the concrete result into their own `error` field. Declaring the result as `error` makes the trap impossible rather than merely avoidable, which is what a public signature should do.

An empty box with a label on it is still a box. The label is the concrete type, and once it is on, a check for "is there a box here?" answers yes even though nothing is inside.

saying these in an interview costs you the question

  • Says a nil concrete pointer is a nil error
  • Returns the concrete type so callers can read its fields
  • Thinks the compiler warns about the typed-nil result
  • Treats it as style rather than a caller-visible defect
  • Widens later and calls the signature change harmless