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