skip to content

You own a semver-parsing library three teams import and cannot patch. How do you decide which failures get an exported error surface?

level: principalimportance: should knowfreq 34%

answer

  1. who would write a different line?
  2. you cannot patch the importers
  3. one condition, one shape, forever
  4. a surface diff sees declarations, not behaviour
  5. write down what is contract

basics

~20 s

Export a surface only where a named importer has a branch that changes behaviour, one shape per condition, and treat every exported error as permanent API. Everything else stays an unexported error with a good message.

solid answer

~50 s

I start from demand, not from what the parser knows. For each failure I ask which of the three teams would write a different line of code if it could tell that failure apart; if none would, it stays unexported and the message goes in a log. Where a branch is real, I pick exactly one shape for that condition, never both a sentinel and a type, and I write into the package doc which values are contract and that wording never is. Because I cannot patch importers, adding a surface is a one-way door, so I prefer the reversible move: sentinel first, richer value later. I run an exported-surface diff between tags so error declarations reach review as API changes, and back it with tests asserting that a given input still classifies the same way, because the diff cannot see a condition changing category.

go deeper

for a junior

Recall that anything a package exports, error values included, is something importers can depend on and the author cannot quietly remove. Unexported errors with clear messages are a normal, complete answer.

for a middle

Be ready to explain why the number of exported error surfaces should be much smaller than the number of conditions a package can distinguish, and what adding one commits the author to.

for a senior

Show the working rule: demand-driven, one shape per condition, and classification pinned by tests because behaviour changes do not show up in an API diff. Say how you would answer a team asking for a new surface.

for a principal

Own the policy and its costs across teams you cannot patch. State when a surface is granted, who reviews it, how it is written down as contract, and what the withdrawal path looks like when you get one wrong.

## The constraint that decides everything Three teams import the library and you cannot patch their code. That single fact converts every exported error into a coordinated migration you own the cost of. Nothing about Go's tooling stops you exporting more; the compiler is perfectly happy. The discipline has to come from a decision you make and can be overruled on, which is why this is an ownership call rather than a technical one. ## Start from demand, not from knowledge The wrong starting point, and the common one, is the parser's internal vocabulary. A version parser distinguishes many things: an empty input, a missing minor component, a non-numeric component, a malformed pre-release, a build-metadata problem, a value that parses but exceeds a range. Exporting a surface for each is easy and looks thorough. It is also a dozen permanent commitments bought with no evidence. The right starting point is a question asked of each importing team: which of these would make you write a different line of code? In practice the answer collapses hard. Most callers do exactly one thing with a parse failure, which is reject the input and report it, and they need no classification at all. A caller reading versions from a registry might want to tell an unsupported-but-valid version from a malformed one, because one is a skip and the other is a bug report. That is one surface, not a dozen. A useful discipline when a team asks for a surface: ask them to show you the branch. If the answer is that they want to log it differently, the message already does that. ## Pick one shape per condition, and prefer the reversible one For each condition that earns a surface, choose exactly one shape and say why: - **Sentinel** when the branch is the whole reaction. Cheapest, and callers can also produce it themselves, which matters for their fakes and adapters. - **Exported type** only when a caller must read a value to act, such as the offset to highlight in a form. Every exported field is then permanent. - **Unexported value behind a predicate** when you expect the representation to move, accepting that callers cannot produce it. Never both a sentinel and a type for one condition. Two contracts for one meaning split your callers and drift. And prefer the reversible direction. A sentinel can later be reported by a richer value, so old callers keep working and new ones get detail. A published field cannot be withdrawn. When the choice is close, take the one you can grow out of. ## Make error surface visible in review Exported error declarations are API, so review them as API. An exported-surface diff between two tags, of the kind the module tooling produces, puts a newly exported error variable, a new field on an error type, or a changed method signature in front of a reviewer as an API change rather than as an ordinary code change buried in a diff. That is the mechanism that stops the surface from growing by accident, one convenient export at a time. Its limit matters just as much, and naming it is what separates a real answer from a tooling recital: a surface diff sees declarations, not behaviour. If the same input that used to report as one category now reports as another, if you add or remove a wrapping layer, or if you reword a message, the declarations are identical and the diff is empty, while a caller's branch changes meaning. Only tests inside your own package pin that. So the classification contract gets test cases, and those tests are as load-bearing as the parser's own. ## Write the contract down The package doc should say, in words, which values are matchable contract and that message wording is not. That sentence is what buys back your freedom later: without it, the first team that matched on prose has a plausible complaint; with it, the answer is settled before the argument starts. ## Where the ownership is Spell out the parts someone can overrule you on. Adding a surface is cheap for you and permanent for the module. Removing one is a major version and a migration across three teams whose release schedules are not yours. If a team wants a surface you think is unjustified, the compromise that costs least is often to keep the value unexported and give them a predicate, then promote it if a second team asks for the same thing. And when a surface really must be withdrawn, the path is to deprecate in place, keep returning it alongside the replacement for at least one release, and only then plan a major version, because the compiler will not find every caller for you. ## What a strong answer sounds like Demand-driven, one shape per condition, reversible by default, surface diffs in review plus classification tests to cover what the diff cannot see, and an explicit written statement of what is contract. A weak answer catalogues the sentinel and type mechanisms again and never states a rule that would tell a reviewer when to say no.

  • A downstream team asks you to export the error type so they can read the offending token. What do you ask first?
    Show me the branch. What do you do with the token that you cannot do with the message, and does user-visible behaviour change? If it is only logged, the message covers it. If the behaviour is real, I still ask whether it should be a method rather than an exported field, so I keep room to change how it is stored.
  • What does an exported-surface diff between two tags fail to catch about your errors?
    Behaviour. Identical declarations can hide an input that now reports as a different category, an added or removed wrapping layer, or a reworded message. All of those change what a caller's branch does while the diff comes back empty, so the classification needs its own test cases inside the package.
  • You must withdraw an exported error surface that turned out to be wrong. What is the path?
    Treat it as a major-version migration you sponsor. Mark it deprecated in place, ship the replacement alongside it and keep returning both for at least one release, tell the three teams before the tag rather than after, and only then cut the major version. The compiler will not find every caller for you.

Every exported error is a key you hand out to buildings you do not own. Cutting another key takes a second; changing the lock afterwards means visiting three teams on their schedule, not yours.

saying these in an interview costs you the question

  • Exports a surface for every condition the parser can distinguish
  • Treats an unexported error with a good message as a failure
  • Assumes an API diff catches a changed error classification
  • Adds a sentinel on request without asking for the branch
  • Plans to unexport an error surface in a patch release