When is an exported IsX(err error) bool predicate a better package surface than exporting the error value itself?
answer
- the caller asks, the package answers
- nothing outside can name the value
- who writes the fake for this failure?
- a bool carries no detail
- one exported function per category
basics
~20 sA predicate over an unexported error keeps the representation private, so the package can change how that failure is built without breaking importers. The cost: callers can only ask the question, never construct or compose the failure themselves.
solid answer
~50 sKeep the sentinel or the type unexported and export only a function such as `func IsUnsupported(err error) bool`. What that buys is freedom: the classification logic stays inside the package, so you can split one category into three, swap a sentinel for a richer value, or change how you wrap, and no importer notices. What it costs is real. Callers cannot produce that failure, so their fake cannot make your predicate say yes, and a middleware wanting to report the same category invents its own. They also get a bool and nothing else: no fields, and no way to write one exhaustive switch over your categories, because each category is a separate exported function. I reach for it when a failure's representation is genuinely unsettled, and I export the value once the shape has stopped moving.
code
go · 7 linesvar errUnsupported = errors.New("semver: unsupported version")
// IsUnsupported reports whether err came from an unsupported version.
// The value it matches stays unexported, so its shape can change.
func IsUnsupported(err error) bool {
return errors.Is(err, errUnsupported)
}go deeper
Recall that a package can answer a question about a failure without handing out the value behind it, by exporting a boolean helper function whose parameter is an error.
Explain the trade: hiding the value keeps the package free to change how the failure is represented, while callers lose the ability to construct it. Note that such a helper should look through wrapping layers.
Show where it bites in real work: a downstream team writing a fake that cannot make the predicate say yes, and a surface that grows one function per category with no exhaustive switch. Say when you would retire it and export the value.
Frame it as the conservative opening move on a surface you cannot take back. Decide as a policy when a category graduates from a private predicate to a published value, and who signs off on that promotion.
## The shape The package declares the error value or type without exporting it, and exports a single boolean function that answers one question about an arbitrary error. The caller's code names the function, never the value. The implementation is normally a matching call against the hidden value, which means the predicate looks through wrapping layers exactly as a direct match would. That is worth stating explicitly, because the oldest predicates in the standard library, such as `os.IsNotExist`, predate wrapping and do not look through it. A predicate you write today should, and a candidate who knows why the older ones behave differently is showing real depth. ## What it buys **Representation stays yours.** Nothing outside the package can name the value, so nothing outside depends on whether it is a sentinel, a struct, or three different values collapsed under one question. You can split one condition into several internally, change what you wrap, or start carrying detail, and importers see no change at all. This is the only shape of the three that leaves the representation genuinely free. **One place owns classification.** The rule for what counts as this category lives in your package, next to the code that produces it. With an exported value, the rule lives in every caller, and every caller's version of it drifts on its own schedule. **A smaller reviewable surface.** One function with an obvious signature is easier to review, document, and diff between tags than a type with fields whose meanings need documenting individually. ## What it costs **Callers cannot produce the failure.** This is the big one and it is easy to miss until someone hits it. A team writing a fake for your interface cannot make your predicate return true for their fabricated error, because the value it matches is unreachable. Their tests then have to either exercise your real implementation or stop testing that branch. The same applies to any adapter or middleware that wants to report the same category on your behalf: it cannot, so it invents a parallel one, and now two categories mean the same thing. **A bool is the whole answer.** No offset, no name, no count. If a caller ever needs detail, the predicate cannot grow to give it and you are exporting a type after all. **No exhaustiveness, and linear growth.** Each category needs its own exported function. A caller who wants to handle several conditions writes a chain of if statements rather than one switch, nothing tells them when you add a category, and your exported surface grows one identifier per category. At a dozen categories the shape has clearly outlived its usefulness, and a single exported type with a category value is the better surface. **Composition is one-way.** A caller can ask, but cannot pass your category along as a value into code that takes an error to match against. ## When to choose it Use it when the representation is unsettled and you expect it to move, when the classification rule is more complex than an identity check and you do not want it copied into callers, or when the condition is one that only your package can ever legitimately produce. Retire it, by exporting the value, once the shape has stopped moving and callers start needing to produce or compose the failure themselves. The honest framing for an interview is that the predicate is the conservative opening move. It gives up caller power in exchange for author freedom, and unlike an exported value, giving it up is a decision you can reverse later.
- What can a caller do with an exported error value that your predicate denies them?Produce it. They can construct it in a fake, wrap it and return it from their own layer so their callers see the same category, hand it to code that takes an error to match against, and compose it into their own classification. A predicate answers a question; a value can be created, passed, and re-reported.
- How does a predicate surface age as the package grows to a dozen failure categories?Badly. Each category costs another exported function, callers write chains of if statements instead of one switch, and nothing tells them a category was added. At that scale export a single type carrying a category value, so callers get one switch and you get one declaration to maintain.
- Should a predicate you write today look through wrapping layers?Yes. Implement it with the standard unwrapping match so it still answers correctly after intermediate layers add context. The oldest predicate helpers in the standard library predate wrapping and only recognise unwrapped values from their own package, which is exactly the trap to avoid repeating.
saying these in an interview costs you the question
- Claims the predicate is strictly better than exporting a value
- Forgets that callers cannot construct the failure in fakes
- Writes a predicate that fails on a wrapped error
- Adds one exported predicate per category without limit
- Thinks a bool can later grow to carry detail