Back to Notes

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 eface with iface: any has 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 *QueryErrorerror, 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.Buffer as io.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

  1. 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]]