Go Interface Internals
Implicit Interface Satisfaction & Method Sets
What: A type satisfies an interface if its method set contains every method of the interface — no implements keyword, checked structurally at compile time at the point of conversion/assignment.
Syntax/Signature: var _ io.Writer = (*MyWriter)(nil) — compile-time satisfaction assertion.
type Notifier interface {
Notify(ctx context.Context, msg string) error
}
type EmailNotifier struct{ addr string }
func (e *EmailNotifier) Notify(ctx context.Context, msg string) error { return nil }
// Compile-time proof; costs nothing at runtime.
var _ Notifier = (*EmailNotifier)(nil)
How it works under the hood: Method set of T = methods with value receivers. Method set of *T = value and pointer receivers. The compiler checks satisfaction at the assignment site; the runtime builds an itab (below) lazily on first conversion. Satisfaction is structural, so packages that have never heard of each other interoperate.
Interview angle: "Why does var n Notifier = EmailNotifier{} fail here?" — because Notify has a pointer receiver, so only *EmailNotifier's method set includes it. Addressability auto-& only applies to direct method calls on addressable values, never to interface satisfaction.
Gotchas / common wrong answers:
- Claiming "Go auto-takes the address when converting to an interface" — it does not; interface conversion copies the value, and a copy has no stable address.
- Forgetting map elements are not addressable, so
m[k].PointerMethod()fails even for direct calls. - Believing satisfaction is checked at runtime — it's compile-time for static types; only type assertions defer to runtime.
Trade-offs / when NOT to use: Don't define interfaces "just in case" in the producer package — Go idiom is consumer-defined interfaces (see [[Go Embedding & Dependency Injection]]). Preemptive interfaces add indirection with no seam that anyone needs.
Interface Internals: iface (itab) & eface
What: A non-empty interface value is a 2-word struct {tab *itab, data unsafe.Pointer}; the empty interface is {_type *_type, data unsafe.Pointer} (eface). The itab caches the method dispatch table for a concrete (type, interface) pair.
Syntax/Signature: Runtime layout (illustrative, from runtime/runtime2.go):
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct { // abi.ITab in 1.22+
inter *interfacetype // the interface's type descriptor
_type *_type // the concrete type
hash uint32 // copy of _type.hash, used by type switches
fun [1]uintptr // variable-length method pointer table
}
var w io.Writer = os.Stdout
// w.tab -> itab for (*os.File, io.Writer): fun[0] = (*os.File).Write
// w.data -> the *os.File pointer itself (pointer types stored directly)
n, _ := w.Write([]byte("x")) // indirect call: w.tab.fun[0](w.data, ...)
_ = n
How it works under the hood: Itabs are computed lazily on first conversion and cached in a global hash table keyed by (interface type, concrete type), so repeated conversions are cheap. fun holds one code pointer per interface method, sorted by name — dispatch is one load + indirect call. If the concrete value doesn't fit in a pointer word or isn't a pointer, conversion heap-allocates a copy and data points to it (escape analysis permitting). hash enables O(1)-ish type switches.
Interview angle: "What's the cost of calling through an interface?" — indirect call (defeats inlining and devirtualization unless PGO kicks in), plus possible allocation at the conversion site, not the call site.
Gotchas / common wrong answers:
- Saying small ints are "packed into the interface word" — Go stopped doing that years ago; non-pointer data is heap-allocated (small integers 0–255 and one-byte values hit a static cache,
runtime.staticuint64s). - Thinking itab construction happens at every conversion — it's cached.
- Confusing
efacewithiface:anyhas no method table at all, so a type assertion is required before any call.
Trade-offs / when NOT to use: On hot paths (per-item in a tight loop), interface dispatch + boxing allocations show up in profiles; consider generics or concrete types (see [[Go Type Assertions & Generics]]).
The Typed-Nil Trap (Nil Interface ≠ Interface Holding Nil)
What: An interface is == nil only when both words (tab/type and data) are nil. Storing a nil typed pointer in an interface produces a non-nil interface.
type QueryError struct{ SQL string }
func (e *QueryError) Error() string { return "query failed: " + e.SQL }
func lookup(id int) *QueryError {
return nil // fine as a concrete pointer
}
func do(id int) error {
return lookup(id) // BUG: wraps nil *QueryError into non-nil error
}
func main() {
fmt.Println(do(1) == nil) // false — itab is set, data is nil
}
How it works under the hood: return lookup(id) converts *QueryError → error, setting tab to the itab for (*QueryError, error) and data to nil. err == nil compares both words; tab is non-nil ⇒ not equal. Calling err.Error() then invokes the method with a nil receiver — which works here until it dereferences e.SQL and panics.
Interview angle: Classic screener: "This function returns nil but the caller's err != nil check fires. Why?" Model fix: declare the return type as error and return nil explicitly, or convert only when non-nil.
Gotchas / common wrong answers:
- "Just compare with
reflect.ValueOf(err).IsNil()" — works but is a smell; fix the producer instead. - Assuming a method call on a nil-data interface always panics — pointer-receiver methods run fine with a nil receiver until they dereference.
- Same trap with any interface, not just
error(e.g., returning a nil*bytes.Bufferasio.Writer).
Trade-offs / when NOT to use: Never return concrete error types from exported APIs; always return the error interface type (see [[Go Error Handling]]). Concrete error return types make this bug structural.
Interview Drills
- This returns "error" even on success — why, and two fixes?
func find(id int) *NotFoundError { ... return nil }
func handler() error { return find(7) }
Typed-nil trap: converting nil *NotFoundError to error sets the interface's itab word, so the interface is non-nil (err != nil is true even though the data word is nil). Fixes: (1) make find return error and return nil explicitly; (2) in handler, check if e := find(7); e != nil { return e }; return nil. Rule: exported functions return the error interface type, never concrete error types.
2. What's the actual cost of calling a method through an interface, versus calling it on a concrete type directly? — An indirect call through the itab's function table (a load + jump, defeating inlining and devirtualization unless PGO kicks in), plus a possible heap allocation at the conversion site if the concrete value isn't already a pointer — the call itself doesn't allocate, but getting the value into the interface in the first place might have.
3. Why does calling a method on a nil-data interface sometimes work and sometimes panic? — A pointer-receiver method runs fine with a nil receiver as long as the method body never dereferences it — the method call itself is just tab.fun[i](nil, ...), which is a valid call; the panic only occurs at the specific line that tries to read/write through the nil pointer.
Cross-links
[[Go Type Assertions & Generics]] · [[Go Error Handling]] · [[Go Embedding & Dependency Injection]] · [[Go Receivers & Control Flow]]