Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 09 30 MIN

Types and structure

Interfaces, and Who Should Define Them

How implicit interface satisfaction works, why the consumer defines the interface, what any costs, type assertions and switches, and the nil-interface trap.

Here is a function that is wrong in a way that compiles, runs, and returns a value nobody expects:

type MyErr struct {
	Key string
}

func (e *MyErr) Error() string { return "key " + e.Key + " missing" }

func broken() error {
	var e *MyErr
	return e
}

func main() {
	err := broken()
	fmt.Println(err == nil)
}

It prints false. No pointer was ever allocated, nothing failed, and the caller takes the error branch. Similar code has shipped in real services and produced pages of "not found" for records that exist, because the bug is invisible in review and the type is printed only when someone remembers to print it.

The trap is not a quirk of the error interface. It is a consequence of how interfaces hold values, and the same reasoning explains why nobody declares which interfaces their type implements, and why an interface belongs in the package that uses it rather than the package that satisfies it.

A type satisfies an interface by having the methods, and nothing announces it

An interface is a type that lists method signatures. Any concrete type with those methods, with matching names and signatures, is usable as that interface type. No declaration links the two, and the concrete type never mentions the interface.

type Memory struct {
	data map[string]string
}

func (m *Memory) Put(key, value string) { m.data[key] = value }

func (m *Memory) Get(key string) (string, error) {
	value, ok := m.data[key]
	if !ok {
		return "", fmt.Errorf("key %q not found", key)
	}
	return value, nil
}

func (m *Memory) Delete(key string) { delete(m.data, key) }

Memory has three methods and no implements clause, because Go has none. Whether it satisfies an interface is a question asked at the point of use, by the compiler, against the interface declared there:

type KeyReader interface {
	Get(key string) (string, error)
}

func onlyReader(r KeyReader) {
	value, err := r.Get("region")
	fmt.Println("onlyReader:", value, err)
}

Inside onlyReader, Put and Delete do not exist. The parameter's type offers one method, so the body can call one method and nothing else, which is the property that makes interfaces useful.

The price is that you cannot look at Memory and see what it implements. Nothing in the package says so, and no tool can promise to find every interface your type satisfies somewhere else in the world. What you can do is write the assertions you depend on as package-level declarations:

var (
	_ KeyReader = (*Memory)(nil)   // this file needs Memory to be a KeyReader
	_ io.Writer = (*strings.Builder)(nil)
)

Each of those lines is a compile-time check with no run-time cost. When one fails, the compiler names the missing method and the file and line where the assertion sits:

./main.go:11:20: cannot use m (variable of type *Memory) as io.Writer value in variable declaration: *Memory does not implement io.Writer (missing method Write)

That message is worth reading twice when the method does exist, because the reason is then the receiver. A method with a pointer receiver is not in the method set of the value type, so T fails where *T succeeds. The previous lesson covers that rule in full.

The consumer defines the interface, and it stays small

Since satisfaction is implicit, the interface can live anywhere. The useful convention is that it lives where it is consumed: the package whose function takes the argument, or the file that needs the behaviour.

The argument for that direction is mechanical. If the producing package defines and exports the interface, every consumer of the type is stuck with one description of it, that description grows every time a new consumer needs one more method, and the producing package starts exporting a contract it cannot predict. When the consumer defines it, each consumer takes exactly the behaviour it uses, and the interfaces stay small because nothing pulls them wider.

// Small interfaces, declared by the code that needs them.
type KeyReader interface {
	Get(key string) (string, error)
}

type Logger interface {
	Printf(format string, args ...any)
}

type Service struct {
	store KeyReader
	log   Logger
}

The service stores a one-method store and a one-method logger. Neither is the full type: Memory has three methods and stdoutLogger has one. A test can pass a map-backed stub for the store and a slice-backed recorder for the log, and neither needs to know that the other exists.

Interface embedding is how you compose small ones without repeating method signatures. An interface can list another interface as a member, and the method sets merge:

type Recorder interface {
	io.Writer
	KeyReader
}

The effect of narrow interfaces is easier to see with types you already have. This program asks three types about three interfaces:

*os.File       Reader:true Sizer:false Wide:false
*strings.Reader Reader:true Sizer:true Wide:true
*strings.Builder Reader:false Sizer:true Wide:false

The method sets of the standard library types decide every answer, and nothing in os or strings mentions these three interfaces. The narrower the interface, the more types satisfy it, and Wide, which embeds io.Reader and adds a method, is the one that most types fail. The anti-pattern looks like this:

// An interface that copies a concrete type's whole method set.
type everything interface {
	Put(key, value string)
	Get(key string) (string, error)
	Delete(key string)
}

Nothing can satisfy everything except a type that already looks like Memory, so the interface has stopped abstracting anything and started documenting one implementation. It also drags every consumer of the type into importing the interface rather than the type, which is a round trip that buys nothing. One method or two is the healthy size. The standard library's own examples, io.Reader and io.Writer, are the shape to copy.

any is the interface that every type satisfies

any is an alias for interface{}: an interface with no methods, which every type satisfies, including values that hold nil. In a signature it means the function will inspect the value at run time, which is exactly what it cannot do at compile time.

func Describe(v any) string

That signature is honest about the loss. A caller passing 42 and a caller passing "orders" both hand over a value the compiler can no longer check against anything, so every use inside the function needs a type assertion or a type switch, and typos in those are found when the code runs.

Use any where the standard library does, in fmt.Println variants and in JSON structures such as map[string]any, where the shape of the data is not known until it arrives. Keep it out of your own function signatures when a small interface or a concrete type would say what you mean, because a signature that takes any has stopped communicating and a caller who passes the wrong thing is not told.

An assertion recovers the concrete type from an interface

A type assertion asks a question about an interface value. The one-value form panics when the answer is no, so it belongs only where the type is certain by construction. The comma-ok form returns the value and a boolean, and it is the form to reach for in code that has not proven the type:

var v any = "hello"

s := v.(string)              // panics if v is not a string
n, ok := v.(int)             // n is the zero value, ok is false
fmt.Printf("comma-ok: n=%d ok=%t\n", n, ok)
asserted: hello
comma-ok: n=0 ok=false

An assertion goes through an interface to the concrete value inside it, which works the same at any interface depth:

asserting through an interface: score=3 ok=true

A type switch is an assertion with cases, and the order decides the match

A type switch compares the dynamic type against each case in order. Concrete types are matched first, then interface satisfaction, and the first match wins:

func Describe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nothing"
	case string:
		return "string of length " + fmt.Sprint(len(x))
	case int:
		return fmt.Sprintf("integer %v", x)
	case int64:
		return fmt.Sprintf("integer %v", x)
	case []string:
		return fmt.Sprintf("list of %d strings", len(x))
	case error:
		return "error: " + x.Error()
	case fmt.Stringer:
		return "stringer: " + x.String()
	default:
		return fmt.Sprintf("unhandled %T", x)
	}
}
<nil>                  nothing
string                 string of length 6
int                    integer 42
int64                  integer 7
[]string               list of 2 strings
*errors.errorString    error: connection reset
main.Score             stringer: 9 points
json.Number            stringer: 3

Two details in that output are easy to miss. The nil case matched the untyped nil interface, which is the only kind of nil that compares equal to nil. And the order of the cases is load-bearing: both *errors.errorString and json.Number reached the fmt.Stringer case without the code knowing either type name, which is what makes interface cases useful and what makes case order a decision rather than a formality.

A nil pointer inside an interface is not a nil interface

Back to the trap. Go's spec settles it: two interface values are equal when they have identical dynamic types and equal dynamic values. An interface value is nil only when both its type and its value are unset. Assign a typed nil pointer to an interface and the type is set, so the comparison against nil is false.

err == nil: false
err type: *main.MyErr
err value: <nil>
the caller takes the failure branch
errors.Is(err, nil): false
fixed() == nil: true
recovered: runtime error: invalid memory address or nil pointer dereference

Read the first three lines together, because they are the whole diagnosis. The interface is not nil, its dynamic type is *main.MyErr, and its dynamic value is a nil pointer. Printing it shows <nil>, which is why the bug survives inspection: the log line looks like success. errors.Is behaves the same way, since it also asks the interface whether it is nil.

The last line is the second half of the cost. A method with a pointer receiver that dereferences its receiver panics on nil, so the failure arrives later, further from the return statement that caused it. The fix is on the producing side, and it has one shape: do not declare a pointer variable, assign it to an interface return, and leave. Return nil in the success path, or return a real value.

func fixed() error { return nil }

Two habits catch this in review. Never write var e *MyErr; return e: if the pointer is nil, the function should return a nil interface, and if it is not, it should be assigned from something. And check a typed nil before wrapping it, because fmt.Errorf("service: %w", typedNil) produces a non-nil error whose message is service: <nil> and whose Unwrap hands back the same nil pointer. The error travels through the chain looking like a failure with a missing description, and errors.As will happily extract a nil *MyErr from it. The whole chain is built on a value that should never have entered it.

Lab 30 minutes

Write a small report builder that is testable because of where its interfaces live.

Define a Report struct with a title and a slice of rows, and a Builder type whose Write method appends a row. Give the builder a second method that is not part of any interface yet.

Then, in the file that consumes it, declare two interfaces: one with the single method a Render function needs to read rows back, and one for the destination it writes to, built on io.Writer so that strings.Builder satisfies it with no extra type.

Finally, write Probe(v any) string that reports the concrete type of its argument and a one-line summary: length for strings and slices, the wrapped error for values that satisfy error, and "unhandled" for anything else. Call it with a nil interface, a nil *Report, and a real report, and record which of the three comparisons against nil were true.

Show the solution and the reasoning

The interfaces belong with the functions that take them:

type Report struct {
	Title string
	Rows  []string
}

type Builder struct {
	rows   []string
	closed bool
}

func (b *Builder) Write(row string) { b.rows = append(b.rows, row) }

func (b *Builder) Close() { b.closed = true }

func (b *Builder) Rows() []string { return b.rows }

// Declared in the consuming file, next to the functions that take them.
type RowSource interface {
	Rows() []string
}

type Sink interface {
	io.Writer
}

func Render(src RowSource, dst Sink) error {
	for _, row := range src.Rows() {
		if _, err := fmt.Fprintln(dst, row+"\t"); err != nil {
			return err
		}
	}
	return nil
}

Builder satisfies RowSource and Sink without either being declared next to it. Close is deliberately outside both, which is the point of the exercise: a method that exists is not a method that belongs in the contract. The test passes a literal-backed stub for the source and an io.Writer for the sink, so nothing in the test constructs a Builder at all. With &out as the sink, where out is a strings.Builder, the program prints:

rendered "a\t\nb\t\n"

Note that the sink interface is not the same as Builder.Write. Builder.Write takes a string; the io.Writer method takes []byte and returns a byte count and an error. A custom type that wants to satisfy io.Writer has to match that exact signature, and this is the most common reason a type "almost" satisfies an interface you expected. fmt.Fprintln already takes an io.Writer, so it does the byte conversion for you.

Probe follows the same shape as Describe, with the interface cases ordered before the default:

func Probe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nil interface"
	case string:
		return fmt.Sprintf("string of length %d", len(x))
	case []string:
		return fmt.Sprintf("slice of %d rows", len(x))
	case *Report:
		return fmt.Sprintf("report %q with %d rows", x.Title, len(x.Rows))
	case error:
		return "error: " + x.Error()
	default:
		return fmt.Sprintf("unhandled %T", x)
	}
}

The three nil comparisons behave differently, which is the answer the lab is after. A nil interface is equal to nil. A nil *Report assigned to an any is not equal to nil, and neither is a nil error that holds a typed nil, so both take the failure path. In Probe, the *Report case matches the typed nil and then dereferences it, which panics. Guard the case with if x == nil before reading fields, or return the interface as nil in the first place.

The choice between those two fixes is worth stating. Guarding in the consumer is defensive and spreads the same check across every caller. Returning nil from the producer fixes it once, at the point where the nil pointer was created, and leaves every consumer with the comparison they expect. Do the second when you own the producer, and the first when you do not.

Progress

Mark this lesson done and it stays marked in this browser.