skip to content

Should a Go library advertise an optional interface for callers to probe, and what does shipping one commit you to?

level: principalimportance: should knowfreq 30%

answer

  1. an interface nobody's signature mentions is still API
  2. adding one is safe, removing one is silent
  3. callers you cannot see are asserting for it
  4. wrappers you do not own can hide it
  5. the fallback is the real contract

basics

~20 s

Ship one only when the capability is optional and callers have a correct fallback. Once callers type-assert for it, its method set is frozen: adding a method breaks implementers, and dropping it degrades callers silently, with no compile error.

solid answer

~50 s

An optional interface is public API even though it never appears in a signature. Its value is that it extends a frozen interface compatibly in both directions: implementations that lack it just fail the probe. Its cost is that every guarantee around it is unenforceable — you cannot compile-check that implementers keep it, and you cannot stop a third-party wrapper from hiding it, which turns a lost capability into a silent regression somebody else's team pays for. So I ship one only when three things hold: the fallback is correct and tested, the difference is speed or an extra rather than semantics, and I can name and export the interface so nobody hand-rolls a literal for it. Then I take on two obligations: every wrapper my package ships forwards it, and I publish an accessor that unwraps to find the capability, so callers are not defeated by wrappers I do not control.

code

go · 19 lines
go
// Flusher is optional: implementations may provide it, and callers
// must work correctly when it is absent.
type Flusher interface {
	Flush() error
}

// AsFlusher looks through wrappers that expose the value they hold.
func AsFlusher(w io.Writer) (Flusher, bool) {
	for {
		if f, ok := w.(Flusher); ok {
			return f, true
		}
		u, ok := w.(interface{ Unwrap() io.Writer })
		if !ok {
			return nil, false
		}
		w = u.Unwrap()
	}
}

go deeper

for a junior

Recall that a capability a caller discovers by type assertion is still part of what your package promises, even though it appears in no function signature.

for a middle

Explain why adding an optional interface is backwards compatible while removing one causes no build failure anywhere, and what that asymmetry implies for maintenance.

for a senior

Show how you would keep the promise in practice: forwarding tests on every wrapper you ship, an accessor that unwraps, and documentation that names the fallback as the contract.

for a principal

Own the call itself — optional versus mandatory, who absorbs the cost when a wrapper hides it, and what your organisation requires of anyone decorating a value that carries capabilities.

## The thing being decided You own a package that many teams import. Some concrete type of yours can do something extra — flush, half-close, report progress, accept a whole stream at once. You can express that as a **mandatory** part of an interface, as a method on a concrete type, or as an **optional interface** that callers discover with a type assertion. The last option is the one with hidden long-term consequences, and this is a decision the package owner makes and can be overruled on: the reviewer of a wrapper, or the team whose fast path evaporated after an upgrade, both have standing. ## Why the mechanism is attractive Go interfaces, once published, are effectively frozen: adding a method to an exported interface breaks every implementer, so it is a breaking change in the strongest sense. An optional interface is the standard escape. It is **backwards compatible in both directions**: existing implementations that lack the capability continue to work because the probe simply returns false, and existing callers that never probe are unaffected. That asymmetry — adding is safe, removing is catastrophic — is the whole shape of the decision. ## What shipping one commits you to 1. **The method set is frozen the moment callers assert for it.** Adding a method to the optional interface breaks implementers exactly as it would for a mandatory one. Optional refers to who must implement it, not to how changeable it is. 2. **You can never see who depends on it.** A probe is a type assertion in someone else's package. Nothing links back to you, no compiler error appears when it stops matching, and there is no build-time signal you can grep for across the ecosystem. 3. **Removal is silent, not loud.** If your concrete type stops implementing it, every probe starts returning false and every caller takes its fallback. That is a performance or feature regression that reaches production without a single failing build or test — the worst failure shape there is. 4. **You inherit the wrapper problem.** Any wrapper — middleware, an instrumented decorator, a test double — that does not forward the method hides it. You cannot enforce forwarding on code you do not own, and the bug report lands on you regardless. ## The checklist I apply * **Is there a correct fallback?** If the answer is "the caller fails" or "the caller gets wrong data", the capability is mandatory, and it belongs in the declared type of the parameter, in a distinct constructor, or on the concrete type where the compiler can enforce it. * **Is the difference observable beyond speed?** An optional interface that changes semantics makes behaviour depend on which concrete type happened to arrive. That is untestable in practice, because tests use one type and production uses another. * **Can I name and export it?** If I do not export it, callers will retype a signature literal at every site, and the day I change anything, their probes fail silently. Naming it also gives implementers something to write a compile-time assertion against. * **How many wrappers exist between me and the caller?** In a layer where decoration is normal, an optional interface will be hidden more often than it is found, and I should reach for a different design. ## The obligations I take on **Every wrapper I ship forwards it.** That is a review rule with a test: for each wrapper type in my package, a test asserts that wrapping a capable value yields a value that still satisfies the optional interface. Cheap, and it catches the regression at the only moment it is catchable. **I publish an unwrapping accessor.** Since I cannot make third-party wrappers forward the method, I document an `Unwrap()` convention for wrappers and ship a helper in my package that walks it looking for the capability. Callers then use my helper rather than a bare assertion, and a wrapper only has to expose what it holds, not to reimplement every capability that will ever exist. **I document the fallback as the contract.** The docs say the capability is an optimisation and that correct callers must work without it. That is what keeps the promise honest when a wrapper appears. ## The alternatives, and when they win * **Put it in the mandatory interface** — right when there is no correct fallback, and affordable when the interface is young or you control every implementation. * **Return a concrete type** from your constructor and let callers hold it — the capability is then compile-time visible, at the price of the coupling that a concrete return type brings. * **A constructor option or an explicit method on the concrete type** — right when the capability is configuration rather than a property of the value. ## How I would explain the tradeoff to a team Optional interfaces buy evolvability and cost enforceability. You gain the ability to extend a frozen contract without a major version and without breaking a single implementer. You give up the compiler's help entirely: nothing checks that the capability is present, nothing warns when a layer hides it, and the failure mode is a service that got slower or quietly lost a feature between two releases. That trade is worth it for optimisations with real fallbacks, and it is a bad trade for anything a caller's correctness depends on.

  • How do you retire an optional interface you regret shipping?
    Slowly and loudly, because you cannot find the callers. Keep the method working, document it as deprecated with the replacement, and add the new capability as a separate optional interface so both are satisfiable at once. Removing the method is a breaking change with no compile-time signal, so it belongs to a new major version of the package, if anywhere.
  • How do you stop your own wrappers from hiding it?
    Make it a test, not a review habit. For every wrapper type the package exports, a unit test wraps a capable value and asserts the result still satisfies the optional interface. It runs in seconds and fails on the commit that adds a wrapper or a method, which is the only moment the regression is visible.
  • A team says your library's fast path disappeared after they added middleware of their own. Whose problem is it?
    Practically, yours to route around, whoever caused it. You cannot make their wrapper forward a method, so you provide the unwrapping accessor and document the convention their wrapper should follow. Then the fix on their side is one small method rather than reimplementing your capability, and the next such wrapper costs nothing.
  • When is a mandatory interface the better call despite the breakage?
    When there is no correct fallback, so a missing capability means wrong behaviour rather than slow behaviour. Then you want the compiler to reject the value at the call site. If the interface is already published, this is a major-version change: add the method to a new interface, accept it in new APIs, and keep the old path until you can remove it.

saying these in an interview costs you the question

  • Treats an optional interface as private because no signature names it
  • Assumes callers can be found and migrated when it is removed
  • Ships a capability with no working fallback
  • Lets the optional path change behaviour, not just speed
  • Relies on third-party wrappers to forward it voluntarily
  • Leaves the interface unexported so callers hand-copy the signature