Interfaces: Satisfying a Contract Without Signing It

Structural satisfaction with no implements keyword, why small interfaces win, io.Reader and io.Writer, the empty interface any, the type-value pair inside every interface value, the typed-nil trap, type assertions and type switches, and accept interfaces / return structs. Compiled and run against Go 1.26.5.

An interface is a set of method signatures. It names a behavior — “anything that can Speak,” “anything that can Write” — without saying a word about what concrete type provides it. This is the central idea in Go’s type system, the one that most shapes how idiomatic Go is written, and it works differently enough from interfaces in most other languages that it’s worth slowing down for.

The difference in one line: in Go, a type satisfies an interface simply by having the right methods. There is no implements keyword, no declaration linking the two, no ceremony at all. The compiler checks, structurally, that the methods exist. Get that idea to click and a lot of Go stops looking strange.

Structural satisfaction, and why it matters

Most languages use nominal typing for interfaces: a class implements an interface only if it says so, by name, at its definition. Go uses structural typing: a type implements an interface if it has the methods, whether or not anyone intended it to.

package main

import "fmt"

// A small interface: one method.
type Speaker interface {
	Speak() string
}

type Dog struct{ Name string }

// Dog has a Speak method, so Dog satisfies Speaker. No "implements Speaker"
// anywhere — the compiler checks structurally.
func (d Dog) Speak() string { return d.Name + " says woof" }

type Robot struct{ ID int }

func (r Robot) Speak() string { return fmt.Sprintf("unit-%d beeps", r.ID) }

func announce(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	var s Speaker // an interface variable

	s = Dog{Name: "Rex"} // Dog satisfies Speaker
	announce(s)

	s = Robot{ID: 7} // so does Robot, unrelated to Dog
	announce(s)
}
Rex says woof
unit-7 beeps

Dog and Robot never mention Speaker. They just have a Speak() string method, and that is sufficient. Assigning a Dog to a Speaker variable compiles because the method set matches. If you deleted Dog’s Speak method, the assignment would fail to compile at that line with a clear message that Dog does not implement Speaker.

Why did Go choose this? Because it decouples. The type and the interface don’t need to know about each other, which means you can write an interface that describes exactly what your function needs and have existing types satisfy it retroactively, without editing them. A type from a library you don’t control can satisfy an interface you just defined, if it happens to have the methods. Nominal typing can’t do that; the type would have had to declare your interface up front, which it couldn’t, because your interface didn’t exist yet.

Keep interfaces small

Structural typing pushes a whole culture: small interfaces. The fewer methods an interface has, the more types satisfy it and the easier it is to implement. The canonical examples live in the standard library’s io package, and they are one method each:

type Reader interface {
	Read(p []byte) (n int, err error)
}

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

That’s the whole of io.Reader and io.Writer. Because they’re so small, an enormous number of types satisfy them: files, network connections, in-memory buffers, HTTP bodies, compressors, hashers. Any function written against io.Writer works with all of them, and you can write a new sink that plugs into the entire ecosystem by implementing one method.

package main

import (
	"fmt"
	"io"
	"strings"
)

// countingWriter satisfies io.Writer (a Write method with that signature) and
// nothing else. Because fmt.Fprintf takes an io.Writer, it works here for free.
type countingWriter struct{ n int }

func (c *countingWriter) Write(p []byte) (int, error) {
	c.n += len(p)
	return len(p), nil
}

func main() {
	// The stdlib's io.Writer is one method: Write([]byte) (int, error).
	var w io.Writer = &countingWriter{}
	fmt.Fprintf(w, "hello, %s", "interfaces")
	fmt.Println("bytes written:", w.(*countingWriter).n)

	// strings.Builder also satisfies io.Writer — same call, different sink.
	var sb strings.Builder
	fmt.Fprintf(&sb, "hello, %s", "interfaces")
	fmt.Println("built string:", sb.String())
}
bytes written: 17
built string: hello, interfaces

fmt.Fprintf doesn’t know or care what it’s writing to; it asks for an io.Writer and calls Write. Our thirty-line counter and the standard library’s strings.Builder both slot in, because both have the one method. The Go proverb captures the culture: the bigger the interface, the weaker the abstraction. Define interfaces with as few methods as the job needs.

Embedding interfaces

Small interfaces stay useful because you can compose them. Just as a struct can embed another type, an interface can embed other interfaces — you name them inside, with no method of your own required, and the composite interface’s method set is the union of all of them. The standard library does exactly this: io.ReadWriter is nothing but io.Reader and io.Writer glued together.

type ReadWriter interface {
	Reader
	Writer
}

A type satisfies the composite when it satisfies every embedded piece — that is, when it has all the methods. Here’s the pattern with a pair of tiny interfaces and one type that implements both:

package main

import "fmt"

type Reader interface {
	Read() string
}

type Writer interface {
	Write(s string)
}

// ReadWriter is composed by embedding the two smaller interfaces.
type ReadWriter interface {
	Reader
	Writer
}

// buffer has both Read and Write, so it satisfies Reader, Writer, AND ReadWriter.
type buffer struct{ data string }

func (b *buffer) Read() string   { return b.data }
func (b *buffer) Write(s string) { b.data += s }

func use(rw ReadWriter) {
	rw.Write("hello ")
	rw.Write("world")
	fmt.Println("read back:", rw.Read())
}

func main() {
	var b buffer
	use(&b) // *buffer satisfies the composed ReadWriter

	// It satisfies the parts too.
	var r Reader = &b
	var w Writer = &b
	fmt.Printf("as Reader: %q, as Writer set: %t\n", r.Read(), w != nil)
}
read back: hello world
as Reader: "hello world", as Writer set: true

*buffer never mentions any of the three interfaces, yet because it has both Read and Write it satisfies Reader, Writer, and the composed ReadWriter all at once — structural satisfaction again, now at the level of a combined contract. This is how the standard library keeps its abstractions both small and combinable: define one-method interfaces, then embed them into the larger contracts a function actually asks for. You get to demand exactly ReadWriter in a signature while every implementer only ever had to write two ordinary methods.

The empty interface: any

If an interface with one method is satisfied by many types, an interface with zero methods is satisfied by every type — nothing is required, so everything qualifies. Go has a name for it: any, which is an alias for interface{} (you’ll see the older spelling in code predating Go 1.18; they are identical). A variable of type any can hold a value of any type at all.

var x any = "hello"
x = 42
x = []string{"a", "b"}

any is the tool for genuinely heterogeneous data — the value decoded from arbitrary JSON, the argument to fmt.Println (which takes ...any). It’s also a blunt instrument: once a value is inside an any, the compiler knows nothing about it, and you have to ask at runtime what it really is before you can do much. Generics (a later chapter) have replaced many former uses of any. Reach for it when the data truly is untyped, not to dodge writing a real type.

What’s inside an interface value: (type, value)

Here is the model that explains every surprising thing interfaces do. An interface value is not just the underlying value. It’s a pair: a dynamic type and a dynamic value. When you assign a Dog to a Speaker, the interface stores both “the type is Dog” and “the value is this particular dog.” Method calls dispatch on the type half; the value half is passed as the receiver.

An interface is nil only when both halves are empty — no type and no value. This sounds like pedantry until it produces one of the most famous bugs in the language.

The typed-nil trap

Watch closely. A function returns an error (an interface). Inside, it has a nil pointer of a concrete error type, and it returns that pointer. The returned interface is not nil, even though the pointer inside it is.

package main

import "fmt"

type myError struct{ msg string }

func (e *myError) Error() string { return e.msg }

// broken returns a *myError that is nil, but declared as the error interface.
func broken() error {
	var p *myError // nil pointer
	return p       // wrapped into a non-nil interface value!
}

func main() {
	var e error = broken()

	// The interface holds (type=*myError, value=nil). It is NOT == nil,
	// because the type slot is filled.
	fmt.Println("e == nil:", e == nil)

	// A truly empty interface — no type, no value — IS == nil.
	var empty error
	fmt.Println("empty == nil:", empty == nil)
}
e == nil: false
empty == nil: true

Read that output twice. broken returned a nil *myError, but the moment that pointer was stored in the error interface, the interface’s type slot became *myError. The value slot is nil, but the type slot is not, so the pair is not both-empty, so e == nil is false. A caller writing the completely reasonable if err != nil sees a non-nil error that has no actual failure inside it, and reports a problem that didn’t happen.

The fix is a discipline: don’t return a typed nil pointer as an interface. Return a literal nil when there’s no error. Functions should be written return nil on the success path, not return someNilPointer. This trap has bitten essentially every Go programmer once; now it won’t bite you.

Getting the concrete type back: assertions and switches

A value inside an interface has lost its static type as far as the compiler is concerned. To get it back — to ask “is this actually a string?” — you use a type assertion, and the safe form returns a second boolean rather than panicking on a mismatch.

package main

import "fmt"

func describe(x any) {
	// Type switch: one branch per possible dynamic type.
	switch v := x.(type) {
	case int:
		fmt.Printf("int, doubled: %d\n", v*2)
	case string:
		fmt.Printf("string of length %d\n", len(v))
	case bool:
		fmt.Printf("bool: %t\n", v)
	default:
		fmt.Printf("unhandled type %T\n", v)
	}
}

func main() {
	var x any = "hello" // an empty interface holds anything

	// Comma-ok type assertion: ask if the dynamic type is string.
	s, ok := x.(string)
	fmt.Printf("assert string: %q, ok=%t\n", s, ok)

	// Asking for the wrong type with comma-ok is safe — ok is false, no panic.
	n, ok := x.(int)
	fmt.Printf("assert int:    %d, ok=%t\n", n, ok)

	describe(42)
	describe("go")
	describe(true)
	describe(3.14)
}
assert string: "hello", ok=true
assert int:    0, ok=false
int, doubled: 84
string of length 2
bool: true
unhandled type float64

Two forms, both here. v, ok := x.(T) is the type assertion: ok tells you whether the dynamic type is T, and when it isn’t, v is T’s zero value and nothing panics. (There’s a one-result form, v := x.(T), that does panic on a mismatch — use the comma-ok form unless you’re certain.) The type switch, switch v := x.(type), is the assertion generalized to many types at once, with v bound to the right concrete type in each branch. When you’re branching on more than one possible type, the type switch is the idiom; a single check is an assertion.

Accept interfaces, return structs

One maxim ties the chapter together: accept interfaces, return structs. Take interface types as function parameters, so callers can pass anything that satisfies the behavior you need — the most flexible possible input. But return concrete types, so callers get the full, specific value with all its methods and fields, and aren’t boxed into whatever interface you guessed they’d want.

fmt.Fprintf follows it: it accepts an io.Writer (maximally flexible) and returns concrete int and error. Applied consistently, this keeps your inputs open and your outputs precise, and it’s a reliable default when you’re deciding whether a given type in a signature should be an interface or a concrete type. Accept the abstraction; hand back the real thing.

Final thoughts

An interface is a set of methods, and a type satisfies it by having those methods — structurally, with no implements keyword and no link between the two. That decoupling is the point, and it drives the small-interface culture that io.Reader and io.Writer exemplify. Every interface value is a (type, value) pair, which explains both any (the zero-method interface everything satisfies) and the typed-nil trap (a non-nil type slot makes a non-nil interface even when the value is nil). Recover concrete types with the comma-ok assertion or a type switch, and when you design signatures, accept interfaces and return structs. This is the idea Go is built around; the rest of the language reads more easily once it clicks.

Next: errors are values — why Go returns failures instead of throwing them, and how to wrap, match, and unwrap them.

Comments