Learn Go Series (#9) - Interfaces and Implicit Satisfaction

Words
2435
Reading
11 min
Listen
Play
2h

Learn Go Series (#9) - Interfaces and Implicit Satisfaction

go-banner.png

What will I learn

  • What an interface is in Go -- a set of method signatures -- and how a type satisfies one implicitly, with no implements keyword;
  • Why implicit satisfaction decouples your code so cleanly, and the "accept interfaces, return structs" rule;
  • Two interfaces from the standard library you will use forever: fmt.Stringer and io.Writer;
  • The empty interface any, type assertions (with comma-ok), and the type switch;
  • The nil-interface subtlety worth knowing before it bites you;
  • How to think in interfaces without over-designing them.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-8, especially methods and receivers from episode 8;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#9) - Interfaces and Implicit Satisfaction

This is the episode where Go clicks. An interface is just a named set of method signatures, and a type satisfies it automatically -- implicitly -- simply by having those methods. There is no implements keyword, no declaration linking the type to the interface. That sounds like a small detail, and it changes everything: your types do not need to know about the interfaces they satisfy, so you can define an interface after the types, in a different package, to describe exactly the behaviour you need. Let us clear last episode's exercises and then see why this is such a good idea.

Here is the frame to keep in mind for the whole episode, because interfaces are where people coming from Java or C# tend to trip. In those languages a type must announce every interface it implements, up front, in its own declaration -- class Circle implements Shape. Go turns that relationship inside out. A type just has methods; interfaces are declared separately, often much later, often by code that has never heard of your type; and the compiler quietly checks the match wherever an interface value is actually needed. The direction of the dependency reverses -- the interface depends on the shape of the method set, not the type on the interface -- and almost everything good about Go's design flows from that one reversal. It is a small mechanism with outsized consequences, and by the end of the episode I want you to see why Go's authors were willing to give up the familiar implements keyword to get it. Having said that, let us get the exercises out of the way and then build up to it piece by piece.

Solutions to Episode 8 Exercises

Exercise 1 -- a mutable stack. Push and Pop reassign the slice field, so they need pointer receivers:

package main

import "fmt"

type Stack struct {
    items []int
}

func (s *Stack) Push(v int) {
    s.items = append(s.items, v)
}

func (s *Stack) Pop() (int, bool) {
    if len(s.items) == 0 {
        return 0, false
    }
    top := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return top, true
}

func main() {
    var s Stack
    s.Push(1)
    s.Push(2)
    v, ok := s.Pop()
    fmt.Println(v, ok) // 2 true
    // Pointer receivers are required: both methods reassign s.items, and a value
    // receiver would change a copy, leaving the caller's stack untouched.
}

Exercise 2 -- embed for reuse. Post embeds Timestamped and gets its Age() method for free:

package main

import (
    "fmt"
    "time"
)

type Timestamped struct {
    CreatedAt time.Time
}

func (t Timestamped) Age() time.Duration {
    return time.Since(t.CreatedAt)
}

type Post struct {
    Timestamped
    Title string
}

func main() {
    p := Post{
        Timestamped: Timestamped{CreatedAt: time.Now().Add(-2 * time.Hour)},
        Title:       "Hello",
    }
    fmt.Printf("%q is about %.0f hours old\n", p.Title, p.Age().Hours())
}

Exercise 3 -- a validating constructor. Reject impossible temperatures, keep the field private:

package main

import (
    "errors"
    "fmt"
)

type Temperature struct {
    celsius float64
}

func NewTemperature(celsius float64) (*Temperature, error) {
    if celsius < -273.15 {
        return nil, errors.New("temperature below absolute zero")
    }
    return &Temperature{celsius: celsius}, nil
}

func main() {
    if _, err := NewTemperature(-300); err != nil {
        fmt.Println("rejected:", err)
    }
    t, _ := NewTemperature(20)
    fmt.Printf("%.1f C\n", t.celsius)
}

Now, interfaces.

An interface is a set of methods, satisfied implicitly

You declare an interface as a list of method signatures. Any type with those methods satisfies it -- automatically, no keyword. Here Shape requires an Area() float64, and both Circle and Square satisfy it just by having that method:

package main

import "fmt"

type Shape interface {
    Area() float64
}

type Circle struct{ Radius float64 }
type Square struct{ Side float64 }

func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (s Square) Area() float64 { return s.Side * s.Side }

// describe accepts ANY Shape; Circle and Square qualify without saying so
func describe(s Shape) {
    fmt.Printf("%T has area %.2f\n", s, s.Area())
}

func main() {
    describe(Circle{Radius: 2})
    describe(Square{Side: 3})
}

Notice what is not here: Circle never mentions Shape. It just has an Area method, and that is enough. The %T verb prints the concrete type behind the interface value. This is structural typing -- "if it has the methods, it fits" -- and it is the opposite of the nominal class Circle implements Shape you may know from Java.

There is a subtlety worth pinning down right away, because it connects straight back to last episode. Whether a type satisfies an interface depends on its method set, and the method set differs between a value and a pointer. If a method has a pointer receiver -- func (c *Circle) Area() -- then only a *Circle has that method in its set, so only a *Circle satisfies Shape, and passing a plain Circle would be a compile error. With value receivers (as above) both Circle and *Circle satisfy the interface, which is one more reason the "keep your receivers consistent" advice from episode 8 matters: the receiver choice quietly decides which of your values can pass through an interface. If you ever get a baffling "does not implement Shape (method Area has pointer receiver)" error, this is what it is telling you.

Why implicit satisfaction is such a good idea

Because a type does not declare the interfaces it satisfies, the consumer of a value gets to define the interface describing exactly what it needs. You write small interfaces at the point of use, and any existing type that happens to have the right methods slots in -- even types from other packages you cannot modify. This is the reasoning behind the Go proverb "accept interfaces, return structs": your functions take the narrowest interface they need, but return concrete types so callers get everything.

Think about what that unlocks in practice. Suppose you are using a type from a third-party package, and you want your own function to accept "anything I can write a log line to". You do not need that package to have anticipated you, and you could not edit it even if you wanted to. You simply declare a one-method interface in your package -- interface{ Log(string) } -- and that third-party type satisfies it the instant it happens to have a matching Log method. No adapter, no wrapper, no inheritance gymnastics. The interface is a description of required behaviour written from the caller's side, and any value that already behaves that way qualifies automatically. This is precisely why idiomatic Go interfaces are small, often just one or two methods: you declare the minimum you actually use, so the widest possible set of types can fit through it. A big interface with a dozen methods is a big promise that few types can keep; a one-method interface is a promise almost anything can satisfy. Small is powerful here, in stead of the other way around. ;-)

The standard library lives by this. fmt.Stringer is a one-method interface -- String() string -- and if your type implements it, every fmt print function uses it automatically:

package main

import "fmt"

type Color struct {
    R, G, B uint8
}

// Implementing String() satisfies fmt.Stringer, so fmt calls it for us.
func (c Color) String() string {
    return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B)
}

func main() {
    c := Color{R: 255, G: 128, B: 0}
    fmt.Println(c)          // #FF8000 -- Println found String()
    fmt.Printf("colour: %v\n", c)
}

You never told fmt about Color. You just implemented String(), and because fmt.Println accepts any and checks "does this satisfy Stringer?" at run time, your method is found and used. That is implicit satisfaction paying off.

io.Writer: one interface, many destinations

The interface you will meet most is io.Writer -- a single method, Write([]byte) (int, error). Files, network connections, buffers, os.Stdout, and strings.Builder all satisfy it, so a function that takes an io.Writer can send its output anywhere without knowing or caring where:

package main

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

// writeReport sends output to anything writable: a file, a buffer, the console.
func writeReport(w io.Writer, title string) {
    fmt.Fprintf(w, "== %s ==\n", title)
}

func main() {
    writeReport(os.Stdout, "straight to the console")

    var buf strings.Builder
    writeReport(&buf, "captured in a buffer")
    fmt.Print(buf.String())
}

The same writeReport writes to the terminal and to an in-memory buffer, with no branching, because both destinations satisfy io.Writer. This is why Go code composes so well: functions speak in terms of small behavioural interfaces, and concrete types plug in. Testing gets easy too -- pass a buffer instead of a file and assert on what got written.

That last point deserves a moment, because it is one of the most practical payoffs of the whole idea. In many languages, testing a function that writes to a file means either touching the real filesystem (slow, needs cleanup, can fail for reasons that have nothing to do with your code) or building some elaborate mock object. In Go you just hand the function a *strings.Builder or a bytes.Buffer -- both satisfy io.Writer -- run it, and read back exactly what it produced, all in memory, no cleanup, no I/O. The function under test never knows the difference, because it only ever spoke to the io.Writer interface. Small interfaces at the boundaries are not an abstract nicety; they are what make Go code so pleasant to test, and you will feel the benefit the first time you write a test that needs no setup and no teardown, just a buffer and an assertion.

Interfaces compose from smaller interfaces

Interfaces can embed other interfaces, exactly the way structs embed types (episode 8). The result is a bigger interface whose method set is the union of its parts. The standard library uses this constantly -- io.ReadWriter is nothing more than io.Reader plus io.Writer glued together:

package main

import "fmt"

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

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

// ReadWriter is the union of both: a type needs BOTH methods to satisfy it.
type ReadWriter interface {
    Reader
    Writer
}

func main() {
    var rw ReadWriter
    fmt.Println("a fresh ReadWriter is nil:", rw == nil) // true
}

This is composition again -- the same principle we saw with struct embedding, applied to behaviour rather than data. You define tiny, focused interfaces (one method each, where you can) and build larger ones by combining them only when a function genuinely needs the combined capability. It keeps every interface honest about what it requires, and it means a type that can only read never has to pretend it can also write just to be useful somewhere. When you see io.ReadWriteCloser in the wild, you now know exactly what it is: three one-method interfaces stacked into one, and a type satisfies it by having all three methods and nothing more.

any, type assertions, and the type switch

The empty interface has no methods, so every type satisfies it. Its modern spelling is any (an alias for interface{}). When you hold an any and need the concrete value back, you use a type assertion, and the comma-ok form makes it safe -- no panic if the type does not match:

package main

import "fmt"

func main() {
    var v any = "hello" // any can hold a value of any type

    if s, ok := v.(string); ok { // safe type assertion
        fmt.Println("a string of length", len(s))
    }
    if _, ok := v.(int); !ok {
        fmt.Println("not an int")
    }
}

When there are several possible types, a type switch is cleaner than a ladder of assertions. Note you can even switch on an interface like fmt.Stringer -- a case matches if the value satisfies it:

package main

import "fmt"

func stringify(v any) string {
    switch x := v.(type) {
    case nil:
        return "nil"
    case int:
        return fmt.Sprintf("int(%d)", x)
    case string:
        return fmt.Sprintf("string(%q)", x)
    case fmt.Stringer:
        return "stringer: " + x.String()
    default:
        return fmt.Sprintf("other (%T)", x)
    }
}

func main() {
    fmt.Println(stringify(42))
    fmt.Println(stringify("hi"))
    fmt.Println(stringify(3.14))
    fmt.Println(stringify(nil))
}

One detail in that type switch is easy to skate past: switch x := v.(type) binds x to the value with its concrete type in each case. Inside case int:, x is an int you can do arithmetic on; inside case string:, x is a string you can call len on; inside case fmt.Stringer:, x is a Stringer you can call .String() on. That per-case retyping is the entire reason the type switch exists -- a plain switch cannot change the static type of a variable from one branch to the next, but the type switch can, and it is what lets each branch treat the value as exactly what it is without a separate assertion. In the default case x keeps its original any type, which is your signal that you have hit something you did not plan for.

The case fmt.Stringer: matches anything with a String() method -- interfaces work as type-switch cases too, and order matters: put the more specific concrete cases before the broad interface case, because the first matching case wins. A word of caution though: reaching for any and type switches a lot is often a sign you wanted a proper interface, or generics (which we reach later in the series). Use any at genuine boundaries -- decoding unknown JSON, a fmt-style variadic API -- not as a habit for ducking the type system.

The nil-interface subtlety

An interface value has two parts under the hood: a concrete type and a value. It is nil only when both are absent. A freshly declared interface variable is nil, and calling a method on it panics -- so guard it:

package main

import "fmt"

type Notifier interface {
    Notify() string
}

func main() {
    var n Notifier // nil interface: no type, no value
    fmt.Println("n is nil:", n == nil) // true

    // n.Notify() here would panic: there is no concrete method to dispatch to
    if n == nil {
        fmt.Println("no notifier configured, skipping")
    }
}

There is a famous deeper trap -- an interface holding a nil pointer is itself not nil, because it still carries a type -- and it is worth naming even though we save the full treatment for next episode, because it surprises everyone exactly once. If a function returns an interface, and somewhere inside it returns a nil pointer of a concrete type, the interface handed back is not nil: it carries the concrete type, with a nil value tucked inside. A caller doing if result != nil sees a non-nil interface and walks straight into a panic the moment it calls a method through it. The safe habit for now is small: when a function's job is to signal "nothing here", return a bare return nil (a genuinely nil interface), not a typed nil pointer, and check interfaces for nil before dispatching through them if they might be unset. We take this apart properly once we have pointers and memory firmly in hand.

The same idea in Python

Python's duck typing is the closest cousin: "if it walks like a duck", any object with the right methods works, checked at run time. Go gives you the same flexibility but verified at compile time -- if a type is missing a method the interface needs, your program does not build:

from typing import Protocol

class Shape(Protocol):
    def area(self) -> float: ...

class Circle:
    def __init__(self, radius): self.radius = radius
    def area(self) -> float: return 3.14159 * self.radius ** 2

def describe(s: Shape) -> None:
    print(type(s).__name__, s.area())

describe(Circle(2))  # works: Circle has area(), no "implements" needed

Python's Protocol (structural typing) is deliberately Go-like, but it is checked by an optional type checker, not the language. Go bakes the check into the compiler: implicit satisfaction with a safety net, which is the combination that makes interfaces feel both flexible and trustworthy.

Exercises

  1. A Stringer of your own. Define a Duration struct (or reuse an int type) representing seconds, and give it a String() method that formats as "Xm Ys". Print a value with fmt.Println and confirm your String() is used automatically.

  2. Write to two places. Write func logLine(w io.Writer, msg string) that writes a timestamped line to any io.Writer. Call it once with os.Stdout and once with a strings.Builder, then print the builder's contents to prove the same function fed both.

  3. A tiny type switch. Write func kind(v any) string that returns "integer", "text", "boolean", or "unknown" using a type switch over int, string, bool, and default. Call it with one value of each kind.

What we learned

  • An interface is a named set of method signatures; a type satisfies it implicitly, just by having those methods -- there is no implements keyword;
  • Because satisfaction is implicit, the consumer defines the interface it needs, even over types from other packages -- hence "accept interfaces, return structs";
  • fmt.Stringer (String()) and io.Writer (Write) are two standard interfaces you will use constantly; implement String() and every fmt print uses it;
  • The empty interface any holds any value; get the concrete value back with a type assertion (use comma-ok for safety) or a type switch, which can even match interface cases;
  • An interface is nil only when it holds neither a type nor a value; calling a method on a nil interface panics, so guard it -- and beware the "interface holding a nil pointer is not nil" trap;
  • Go gives you Python-style duck typing but checked at compile time, so a missing method is a build error, not a run-time surprise.

Next episode we go under the hood a little: pointers, what & and * really do, how Go's memory works, and the compiler's escape analysis that quietly decides whether a value lives on the stack or the heap. See you there.

Bedankt en tot de volgende! ;-)

scipio@scipio