Interfaces: implicit satisfaction, the typed-nil trap, and what indirection costs
Go interfaces are satisfied implicitly and stored as a (type, value) pair — which is why a nil *T returned as error makes err != nil true, the classic prod bug. Keep interfaces small, accept interfaces and return structs; dispatch blocks inlining and forces heap escapes.
A deploy gate checked service health: if err := check(); err != nil { rollback() }. One refactor later, every single deploy rolled back — including ones where the service was perfectly healthy. The culprit: check() was declared to return the concrete type *HealthError, and a wrapper assigned that result to an error variable. On the healthy path check() returned a nil *HealthError — but stored into the error interface, that nil pointer became an interface value with a type (*HealthError) and a nil data word. An interface is nil only when both words are nil, so err != nil was true with no error in sight. Calling err.Error() would even have worked or panicked depending on the method’s receiver handling. Three releases were rolled back before someone printed fmt.Sprintf("%#v", err) and saw (*HealthError)(nil) staring back. The FAQ calls it the most-asked question about Go errors; production calls it Tuesday.
Implicit satisfaction: the contract nobody signs
A Go type satisfies an interface by simply having the methods — no implements keyword, no declaration, no import. If your struct has Read(p []byte) (int, error), it is an io.Reader, even if io was never imported and the interface was written years later. This is structural typing checked at compile time, and it inverts the dependency arrow: the consumer defines the interface it needs, and any producer that happens to fit, fits. You can define a one-method interface in your package and the standard library’s types already satisfy it — retrofitting an abstraction onto code you don’t own, with zero changes to that code. Because nothing declares intent, the idiom for “I promise this type satisfies that interface, fail the build if not” is a compile-time assertion:
type Store interface {
Get(ctx context.Context, key string) ([]byte, error)
}
// Compile-time proof: *RedisStore satisfies Store. Costs nothing at runtime.
var _ Store = (*RedisStore)(nil)The (type, value) pair — and the typed-nil trap
Under the hood an interface value is two machine words (16 bytes on 64-bit): a pointer to type/method metadata — the itab, holding the concrete type and the resolved method addresses — and a pointer to the data. Assigning a concrete value to an interface fills both words. This representation explains everything weird about interfaces. Equality compares both words. A type assertion reads the type word. And the trap from the Hook: an interface is nil only when both words are nil. Store a nil *HealthError into an error and the type word gets set (*HealthError) while the data word stays nil — the interface itself is not nil:
type HealthError struct{ Reason string }
func (e *HealthError) Error() string { return e.Reason }
func check() *HealthError {
return nil // healthy: nil pointer of CONCRETE type
}
func main() {
var err error = check() // (type=*HealthError, data=nil)
fmt.Println(err == nil) // false — type word is set!
fmt.Printf("%#v\n", err) // (*main.HealthError)(nil) — the tell
}The rule that prevents it: functions that can fail return the error interface type, never a concrete error pointer type. Inside, return a literal nil, not a possibly-nil typed variable. When you’re debugging a “non-nil nil,” %#v (or %T) exposes the type word that %v hides. This isn’t a compiler bug to route around — it falls straight out of the two-word representation, and once you see the pair, the behavior is the only one possible.
Small interfaces, and accept-interfaces-return-structs
How do you know when to introduce an interface and how big to make it? The standard library’s answer is consistent: its most-used interfaces have one method: io.Reader, io.Writer, fmt.Stringer. That’s deliberate — the bigger the interface, the weaker the abstraction. A one-method interface is satisfiable by hundreds of types and composable into bigger ones (io.ReadWriteCloser is three one-method interfaces embedded); a twelve-method interface is satisfiable by exactly one struct plus the mock you wrote for it, which means it’s not an abstraction, it’s a header file. The companion rule: accept interfaces, return structs. Parameters typed as small interfaces ask for the minimum capability — take an io.Reader, not an *os.File, and the function works on files, network connections, buffers, and test strings alike. Returns typed as concrete structs preserve the full method set and let callers decide what to abstract; returning an interface hides methods callers may need and freezes your API around today’s guess. (The known exception: return the error interface — see the previous section for why that one’s mandatory.)
// Asks for the minimum, works with anything readable.
func CountLines(r io.Reader) (int, error) { ... }
// Returns the concrete type: callers get the full *Parser API
// and can still assign it to any interface THEY define.
func NewParser(opts ...Option) *Parser { ... }Type switches are the inspection tool when one value may be several things: switch v := x.(type) branches on the dynamic type and binds v with the right static type per case. Idiomatic for protocol decoding and capability probing (does this io.Writer also implement Flusher?); a smell when a growing switch over your own types replaces what a method on the interface should have been.
What indirection costs — and when it matters
Interface calls are dynamic dispatch: load the method address from the itab, jump. The call itself is a couple of nanoseconds — close to a direct call and rarely the issue. The real costs are second-order. Inlining is blocked: the compiler can’t inline a callee it can’t identify, so a one-line getter behind an interface stays a real call and everything that inlining would have unlocked (constant folding, dead-code elimination, keeping values in registers) is off. Escape to the heap: storing a value in an interface generally forces it to be heap-allocated, because the interface’s data word holds a pointer — pass an int as interface{} in a hot loop and you may allocate on every iteration, which is GC pressure, not just nanoseconds. Honest numbers: a direct call ~1–2 ns, an interface call ~2–4 ns, but a lost inline plus a forced allocation can turn a free operation into ~25–50 ns with GC amortization — a 10–30× difference in the hottest loops and irrelevant outside them. The compiler devirtualizes when it can prove the concrete type (and profile-guided optimization, Go 1.21+, devirtualizes hot call sites from production profiles), but you shouldn’t count on it across package boundaries. The engineering posture: design with interfaces at architectural seams — storage, transport, clock — where the call frequency is per-request, and keep per-element hot paths (codecs, parsers, tight loops over millions of items) concrete or generic; measure with a profiler before flattening any abstraction in the name of speed.
▸Why this works
Why doesn’t the language just make a typed-nil interface compare equal to nil? Because it would break the representation’s honesty. The interface holds real information — “I contain a HealthError” — and that information is legitimately useful: a nil *bytes.Buffer still has methods that work on nil receivers, and code may call them through the interface. Collapsing (type, nil) to nil would make a value’s behavior depend on whether you happened to test it against nil first, and would make storing-then-retrieving a nil pointer lossy: you’d put a typed nil in and get untyped nothing out. Go chose a consistent rule — nil means both words empty — and pushed the burden to one convention: return the error interface, return literal nil. The trap exists, but it’s at least a stable, learnable trap rather than a special case that lies about what the value contains.
func check() *HealthError returns nil on success. A caller writes var err error = check(). What does err != nil evaluate to, and why?
A profiler shows a hot loop calling a one-line getter through an interface, allocating on every iteration. The loop processes 50 million items. What is the dominant cost and the right fix?
- 01Explain the typed-nil-in-interface bug: mechanism, symptom, and the convention that prevents it.
- 02What do small interfaces and accept-interfaces-return-structs buy, and what does interface indirection actually cost in a hot path?
Go interfaces are satisfied implicitly: a type with the right methods satisfies the interface with no declaration, which lets the consumer define the abstraction it needs and retrofit it onto types it doesn’t own; the compile-time assertion var _ I = (*T)(nil) documents and enforces the claim. At runtime an interface value is two words — an itab pointer carrying the concrete type and resolved method addresses, and a data pointer — and every odd behavior falls out of that pair. The production-classic typed-nil trap: a function declared to return *HealthError returns nil, the caller stores it in an error, the type word gets filled while the data word stays nil, and err != nil is true on the healthy path — deploy gates roll back working releases until someone prints %#v and sees the typed nil. The convention that prevents it: return the error interface, return literal nil. Design pressure goes toward small interfaces — io.Reader and io.Writer have one method, compose by embedding, and are satisfiable by hundreds of types, whereas a twelve-method interface abstracts exactly its one implementation — and toward accept interfaces, return structs: parameters ask for minimal capability, returns preserve the concrete method set for callers to abstract as they wish. Type switches branch on dynamic type for decoding and capability probing. Indirection’s price is second-order: dispatch through the itab costs a couple of nanoseconds, but it blocks inlining and boxes values onto the heap, so a free inlined access can become a call plus an allocation — a 10–30x swing across millions of iterations and noise per request. Devirtualization and PGO recover some cases; the discipline is interfaces at architectural seams, concrete or generic code in measured hot loops. Now when you see a function that returns a concrete *FooError instead of error, you know the trap it’s walking into — and the one-line fix that prevents three released rollbacks.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.