When should a Go package export a sentinel error value rather than an exported error type?
answer
- ask what the caller's next line does
- a branch needs identity, an action needs data
- every exported field is forever
- who can produce this value besides you?
- one of the two shapes is reversible
basics
~20 sExport a sentinel value when the caller only needs a yes-or-no branch, and an error type when the caller needs data to act on. A sentinel commits the package to one identity; a type commits every exported field forever.
solid answer
~50 sDecide from what the caller will actually do, not from what the package knows. If the only reaction is a branch, an exported `var ErrInvalidVersion = errors.New(...)` is the smallest permanent commitment: one identity, no payload, and any caller or fake can produce it. If the caller needs a value to act on, such as the offset a parse failed at, only an exported struct type carries it, and now the type name, its receiver, and every exported field are contract you cannot take back. The type also couples harder: code that only wants a branch must import your package to name the type. My default is sentinel first, because it is the reversible choice. A package can later return a richer value that still reports as that sentinel, but it can never unexport a field once importers read it.
code
go · 12 lines// A: identity is the whole contract.
var ErrInvalidVersion = errors.New("semver: invalid version")
// B: the type name, the pointer receiver and both fields are contract.
type ParseError struct {
Input string
Pos int
}
func (e *ParseError) Error() string {
return fmt.Sprintf("semver: invalid version %q at offset %d", e.Input, e.Pos)
}go deeper
Recall the two shapes: a package-level exported error variable versus a struct type with an Error method. Be able to say that only the second one can carry data such as a position or a count.
Explain the choice by what the caller does next, and name the cost of each: one permanent identity versus a permanent type with permanent fields. Mention that a caller can produce a sentinel themselves but must import your package to name your type.
Argue the reversibility point: a sentinel can later be reported by a richer value, while a published field cannot be withdrawn. Show that you would export nothing by default and promote only when a real caller has a real branch.
Own the rule for a whole codebase or a published library. Decide which conditions are worth a surface at all, forbid exporting two shapes for one condition, and state in the package docs what is contract so the team can keep changing everything else.
## Two shapes, two different promises Both shapes answer the same question, namely how a caller distinguishes one failure from another, but they commit the package to very different things. **The sentinel.** A package-level exported error variable. Its entire signal is its identity. It carries no data beyond a fixed message. The promise the author makes is small and precise: this variable exists, and this condition reports as it. **The exported type.** A struct with an `Error() string` method and exported fields. Its signal is its type plus whatever it carries. The promise is much larger: the type name exists, its exported fields exist with these names, these types, and these meanings, and the method set stays put. Whether the package returns the type by value or by pointer becomes contract too, because callers write the concrete type in their own code. ## Start from the caller's next line The useful question is never how much the package knows about the failure. It is what a caller does differently. - The caller takes a different branch and nothing more: a sentinel is enough. Reporting invalid input to the user instead of retrying needs no payload. - The caller needs a value to act with: a type is required. Pointing at the offending offset in a form, waiting a specific number of seconds, or naming the field that failed cannot be done with an identity alone. - The caller wants to distinguish many closely related conditions: consider a single exported type with a category field over a fleet of separate sentinels. Callers can switch on the category and the package can add categories later without touching the number of exported declarations. If no caller reacts differently, export neither. An unexported error with a good message is a complete answer, and the vast majority of failures in a well-designed package should be exactly that. ## What each one costs A sentinel costs the package one exported identifier that can never be removed, renamed, or repointed. It costs the caller almost nothing: no knowledge of your types, no coupling beyond the one variable, and the freedom to produce that value themselves in an adapter, a fake, or a middleware layer that wants to report the same category. That last property is a real feature, not a side effect. A type costs much more. Every exported field is a permanent commitment to keep populating it meaningfully, and to keep its meaning stable. Adding a field later is compatible for callers who read fields by name, but it breaks anyone who constructed the value with an unkeyed composite literal, which people do in tests. Removing or renaming a field breaks everyone. The receiver choice sticks: once you return a pointer to your type and callers name that pointer type, you cannot move to a value receiver without breaking them. And a type couples callers structurally, because code that only wants a branch now has to import your package to name your type. One related shape rule: declare the function's result as `error`, not as your concrete error type, even when that is what you always return. Returning a concrete error type in the signature has a well-known hazard of its own, and there is no benefit to paying for it here. ## Sentinel first, because it is reversible The deciding argument in most reviews is not which shape is nicer but which one you can back out of. A package that starts with a sentinel and later discovers callers need detail can return a richer value that still reports as that sentinel: old callers keep their branch, new callers read the detail. The sentinel stays true. A package that starts with a type has already published every field. If it turns out that only one of them was ever meaningful, or that the shape was wrong, there is nowhere to retreat to. The declarations are out, importers are compiling against them, and shrinking the type is a major version. So the default is: export nothing, promote to a sentinel when a real caller has a real branch, and promote to a type only when a real caller has to read a real field. Do not export both a sentinel and a type for the same condition. Two surfaces for one condition split callers across two contracts you then have to keep aligned forever, and the moment they drift, one group of callers is quietly wrong. ## What an interviewer is listening for They want the decision driven by caller behaviour, the word permanent applied to both shapes, and an honest account of what the type costs beyond the sentinel. The weak answer describes the two mechanisms accurately and never says which one it would pick or what it would give up by picking it.
- Once you export a sentinel error value, what have you promised callers forever?That the identifier exists, that this condition reports as it, and that it keeps meaning the same thing. You can reword its message, but you cannot repoint it, unexport it, or quietly stop returning it for that condition. Removing it is a breaking change even though only the importers who reference it fail to build.
- What does adding a new exported field to an already-published error struct cost you?It compiles for callers who read fields by name, but it breaks anyone constructing the value with an unkeyed composite literal, which happens in tests and fakes. More importantly it is permanent: you now owe every caller a meaningful value in that field on every path that returns the type.
- A failure needs both a yes-or-no category and a numeric detail. How do you shape that?One surface, not two. Export the type that carries the detail and make it report the category as well, so a caller who only wants the branch never touches the fields. Exporting a separate sentinel alongside the type gives you two contracts for one condition that can drift apart.
saying these in an interview costs you the question
- Picks the shape from what the package knows, not what callers do
- Treats an exported error struct field as easy to change later
- Exports both a sentinel and a type for the same condition
- Thinks removing an exported sentinel is a minor change
- Declares the function result as the concrete error type