all_lessons/Go/05 · Interfaceslesson 6 / 18

Interfaces: depend on a behavior, not on a type

A method (Lesson 04) attaches behavior to one concrete type. But the whole point of programming in the large is that code on one team should not know — or care — which concrete type another team will hand it. The interface is Go's answer, and it is a deeply un-Go-looking idea executed in the most Go way possible: a type satisfies an interface by having the methods, with no declaration, no implements, no shared base class. This lesson explains why that "structural" choice is the bargain in its purest form, what an interface value actually is at runtime, the one nil trap it creates, and the two design rules — small interfaces, "accept interfaces, return structs" — that turn the feature into team-scale leverage.

The thesis, here
Inheritance couples a type to a family tree: to be usable as a Shape, a class must declare that it extends Shape, forever binding the two and forcing every consumer to agree on the hierarchy up front. Go judged that coupling a cost the reader and the team pay for years. Its alternative — satisfaction by structure, not declaration — lets the consumer define the small behavior it needs and lets any type, including one written before that interface existed, satisfy it for free. The complexity of an inheritance hierarchy is left out; what you buy is decoupling between teams that never had to coordinate. That is question 4 of the five — does this depend on a behavior or a concretion? — built into the type system.
Linear position
Prerequisite: Lesson 04 — methods on named types, and the value-vs-pointer receiver choice plus the method set rules that follow from it (this lesson leans hard on method sets). Lesson 02's value semantics and the slice/struct layout underneath.
New capability: define an interface as a behavior contract, know exactly what an interface value holds at runtime (a type, data pair), explain why a non-nil interface can wrap a nil pointer and bite you, and design APIs that depend on small consumer-defined behaviors instead of concrete types.
The plan
Four moves. (1) The engineering pain: code welded to a concrete type, and the inheritance "fix" that costs more than it saves. (2) Go's answer — implicit, structural satisfaction — and why no implements keyword is a feature, not an omission. (3) What an interface value is at runtime: the two-word (type, data) pair, the cost of a method call through it, and the typed-nil trap that pair creates. (4) The two design rules that make interfaces pay: keep them small, and "accept interfaces, return structs."

1 · The problem: code welded to a concrete type

Say you write a function that logs an event somewhere. Today "somewhere" is a file. So you write it against *os.File:

func Log(f *os.File, msg string) {
    f.WriteString(time.Now().Format(time.RFC3339) + " " + msg + "\n")
}

This works exactly once, then becomes a liability. Tomorrow someone wants to log to a network socket; next week to an in-memory buffer in a test; next month to a gzip stream. Each is a different concrete type, and Log has welded itself to *os.File. The function never actually needed a file — it needed something it could write bytes to. It depended on a concretion when all it used was a behavior. That is the maintenance bill question 4 is built to catch.

The object-oriented tradition has a fix, and it is instructive to see why Go rejected it. You declare an abstract base — abstract class Writer or interface Writer with an explicit implements — and every writable thing must declare that it extends or implements it. That decouples Log from *os.File, but it introduces a new coupling: every concrete type is now bound to the hierarchy, the hierarchy must be designed before any consumer can use it, and a type you don't own (one from a third-party package, or the standard library, written before your interface existed) cannot participate because it never declared the membership. The decoupling at the call site was bought with coupling everywhere else. Under the Go bargain, that is complexity that was not earned.

2 · Go's answer: satisfaction by structure, not declaration

An interface in Go is a named set of method signatures — a behavior contract — and nothing more:

type Writer interface {
    Write(p []byte) (n int, err error)
}

The pivotal decision: a type satisfies Writer if and only if its method set includes Write(p []byte) (int, error). There is no implements keyword, no declaration, no link from the type back to the interface. The compiler checks the methods are present; satisfaction is structural. *os.File, *bytes.Buffer, net.Conn, a gzip.Writer — none of them mention this interface, and all of them satisfy it, because they all happen to have a matching Write method. (This exact interface is io.Writer in the standard library; Lesson 13 is built around it.) Rewrite Log to ask for the behavior:

func Log(w io.Writer, msg string) (int, error) {
    return io.WriteString(w, time.Now().Format(time.RFC3339)+" "+msg+"\n")
}

Now Log works on a file, a socket, a buffer, a gzip stream, an HTTP response, and a thing your colleague will write next year — none of which had to know Log exists. The decoupling is total and cost the implementing types nothing.

Nominal (Java/C#/C++)
A type implements an interface only by declaring it: class F implements Writer. The relationship is established at the type's definition, by its author, before any consumer exists. Types you don't own can't be retrofitted.
Structural (Go)
A type satisfies an interface by having the methods, checked at the point of use. The author of the type need not know the interface exists. The consumer declares the contract it needs; any matching type — past, present, third-party — fits.
Why this is the bargain
The complexity of an explicit hierarchy that all parties must agree on up front is left out. What you buy is decoupling between teams and packages that never coordinated — the precise problem of programming in the large.

One guardrail you'll want: because nothing links a type to the interfaces it satisfies, it is easy to think a type implements an interface and be silently wrong. The idiom is a compile-time assertion — a throwaway assignment to the blank identifier that fails to compile if the contract is broken:

var _ io.Writer = (*MyType)(nil)  // compile error if *MyType lacks Write

3 · What an interface value actually is

An interface variable is not the concrete value, and it is not a pointer to it in the usual sense. At runtime an interface value is a two-word pair (16 bytes on a 64-bit machine): a pointer to a type descriptor (which concrete type is in here, including its method table) and a pointer to the data (the concrete value, on the heap if it doesn't fit in a word). Internally this is the itab (interface table — type info + the resolved method pointers) and the data word.

io.Writer value +------------+------------+ | type ptr | data ptr | +-----+------+------+-----+ | | v v *os.File's the actual itab: type + *os.File method set (its fd, etc.)

Two consequences fall straight out of this layout. First, a method call through an interface costs an indirection. w.Write(b) reads the method pointer out of the type descriptor's table and calls it — it cannot be inlined the way a direct call on a concrete type can. The cost is a few nanoseconds, not free; in a hot inner loop called millions of times this is measurable, which is the honest reason you don't reach for an interface where a concrete type does the job. Second, putting a value into an interface may copy it to the heap. Assigning a large struct value (not a pointer) to an interface boxes a copy; this is one of the cases the escape analysis from Lesson 03 resolves as "escapes to heap." Passing a pointer into the interface avoids the copy and is the common case.

The typed-nil trap — read this twice
An interface value is nil only when both words are nil — no type and no data. The moment you store a concrete value in it, the type word is set, so the interface is not nil even if the data word is nil. This produces Go's most infamous bug:
func do() error {
    var p *MyError = nil   // a typed nil pointer
    if somethingFailed {
        p = &MyError{...}
    }
    return p               // BUG: returns a NON-nil error wrapping a nil *MyError
}

if err := do(); err != nil {   // TRUE even on success — type word is *MyError
    // ...wrongly taken
}
The fix is to never return a typed nil through an interface — return a literal nil:
func do() error {
    if somethingFailed {
        return &MyError{...}
    }
    return nil   // a true nil interface: both words nil
}
This is the direct runtime consequence of the (type, data) pair, and it is asked in interviews precisely because it forces you to know that an interface is two words, not one.

4 · The two design rules that make interfaces pay

The feature is only leverage if you use it the Go way. Two rules carry almost all of the value.

4.1 · Small interfaces — "the bigger the interface, the weaker the abstraction"

A large interface (ten methods) is barely more abstract than a concrete type: almost nothing can satisfy it except the one struct it was carved from, and every consumer is coupled to all ten methods even if it calls one. A small interface — one or two methods — is satisfied by a huge population of types and demands almost nothing of its consumers. The standard library's most reused abstractions are one method each:

InterfaceMethod(s)Why small wins
io.ReaderRead(p []byte) (int, error)files, sockets, buffers, HTTP bodies, crypto streams — all readable
io.WriterWrite(p []byte) (int, error)the same population, on the output side
fmt.StringerString() stringanything that can describe itself for printing
errorError() stringthe entire error system (Lesson 07) is just this one-method interface
sort.InterfaceLen / Less / Swapthree is already near the practical ceiling for broad reuse

Because io.Copy(dst Writer, src Reader) depends only on those two one-method behaviors, it copies between any reader and any writer — a file to a socket, a buffer to a gzip stream — without ever naming a concrete type. That single function is the dividend of small interfaces, and it is why Lesson 13 calls the standard library a teacher of taste.

4.2 · Accept interfaces, return structs

The second rule is about where an interface should appear in a signature. Take interfaces as parameters; return concrete types. Accepting an interface maximizes who can call you — the input rule from §1. Returning a concrete type maximizes what the caller can do with the result: they get the full type with all its methods and fields, and they decide which narrow interface to view it through, rather than you pre-deciding and throwing capability away.

// Good: callable by anything readable; returns a concrete, fully-featured type.
func NewScanner(r io.Reader) *Scanner { ... }

// Usually worse: forces every caller into your narrow view of the result,
// and hides the concrete type's other useful methods.
func NewScanner(r io.Reader) ReaderCloser { ... }

The companion rule — define the interface where it is consumed, not where the type is defined — completes the picture. The package that uses a behavior declares the small interface it needs; the package that provides the concrete type ships the struct and stays ignorant of every interface that will ever view it. This is the structural-satisfaction payoff made into a layout convention: dependencies point inward, toward the consumer's small contract, and no provider is ever coupled to a consumer it has never heard of.

What interfaces buy
  • Team decoupling. Consumers declare the behavior they need; any matching type, including ones you don't own, fits — no hierarchy to agree on.
  • Testability for free. Accept io.Reader and a test passes a strings.Reader; accept a small interface and a test passes a fake — no mocking framework, no inheritance.
  • Polymorphism without family trees. Question 4 answered in the type system: code depends on behavior, not concretion.
What they cost (named honestly)
  • An indirect call. A method through an interface is a table lookup, a few ns, not inlinable — avoid in the hottest loops.
  • Possible heap boxing. Storing a large value (not a pointer) in an interface copies it to the heap (Lesson 03).
  • The typed-nil trap. The (type, data) pair makes err != nil lie if you return a typed nil — a real footgun (§3).
  • Satisfaction is invisible. Nothing links a type to its interfaces; without a var _ I = ... assertion a broken contract surfaces only at the use site.

Checkpoint exercise

Try it
Find a function you've written (any language) that takes a concrete type — a File, a SQLConnection, a Logger class — and list the methods it actually calls on that argument. Almost always it's one or two. Now define the smallest interface that names just those, change the parameter to it, and notice three things: (1) the function is suddenly testable with a fake; (2) two unrelated types you never considered now qualify as inputs; (3) you removed a dependency edge in your import graph. Then ask question 4 across the rest of that file — every parameter that's a concrete type when only a behavior is used is a coupling you're paying for. That conversion, done reflexively, is most of what "designing with interfaces" means.

Where this points next

Interfaces give you polymorphism — abstraction across types by behavior. The missing half is how you build bigger types out of smaller ones without the inheritance Go refused. Lesson 06 supplies it: embedding, where a struct includes another type and promotes its fields and methods so the outer type satisfies interfaces through its parts. It also closes the loop on this lesson — the empty interface any (an interface with zero methods, satisfied by everything), and how you safely pull the concrete type back out of an interface value with type assertions and type switches, reaching directly into the (type, data) pair you just learned about. Composition is what you do instead of a class hierarchy; interfaces are how the result stays usable.

Takeaway
An interface is a behavior contract — a set of method signatures — and a type satisfies it implicitly, by having the methods, with no implements declaration and no base class. That structural satisfaction is the Go bargain answering question 4 (depend on a behavior or a concretion?): it leaves out the up-front-agreed inheritance hierarchy and buys decoupling between teams and packages that never coordinated, including the ability to retrofit types you don't own. At runtime an interface value is a two-word (type, data) pair — which makes a method call an un-inlinable table lookup of a few nanoseconds, can box a large value to the heap, and produces the typed-nil trap where a non-nil interface wraps a nil pointer and makes err != nil lie. Two rules turn the feature into team-scale leverage: keep interfaces small (one or two methods are satisfiable by everything — io.Reader/Writer power io.Copy), and accept interfaces, return structs, defining each interface in the package that consumes it. The transferable habit: in any language, take the narrowest behavior you actually use, not the widest type you happen to have.

Interview prompts