10 / 20 · Day 4
Day 4 · Concept 10

Interfaces

Go interfaces are structural — a type satisfies an interface by having the right methods, no implements declaration needed. The interface itself is just a list of method signatures. Tiny interfaces (one or two methods) compose better than large ones. "Accept interfaces, return structs."


1 · The intuition

In Java or C#, you write class Dog implements Animal. In Go, you just write the methods — if they match the interface's signatures, the type satisfies the interface, automatically. The relationship is structural, not nominal. The compiler checks at compile time; there's no runtime cost beyond a single indirect call.

The Go proverb. "The bigger the interface, the weaker the abstraction." — Rob Pike. Most production Go interfaces have one method. The standard library's io.Reader, io.Writer, fmt.Stringer all do exactly one thing.

2 · Try it

go main.go · the canonical Stringer
package main

import "fmt"

// Built-in interface, defined in fmt:
//   type Stringer interface { String() string }
// Anything that has a String() string method satisfies it.

type Money struct{ Cents int }

func (m Money) String() string {
    return fmt.Sprintf("$%.2f", float64(m.Cents)/100)
}

type Greeting struct{ Name string }

func (g Greeting) String() string {
    return "Hi, " + g.Name + "!"
}

// Accepts anything Stringer
func describe(s fmt.Stringer) {
    fmt.Println(s)  // Println calls String() if available
}

func main() {
    describe(Money{Cents: 1999})
    describe(Greeting{Name: "Go"})
}

Neither Money nor Greeting says "implements Stringer". They just have a String() string method, and that's enough.

3 · Defining your own interface

go main.go · accept interfaces, return structs
package main

import "fmt"

// A tiny interface — one method.
type Greeter interface {
    Greet() string
}

// Concrete types implement it.
type English struct{ Name string }
type Klingon struct{ Name string }

func (e English) Greet() string  { return "Hello, " + e.Name }
func (k Klingon) Greet() string  { return "nuqneH, " + k.Name }

// Function accepts Greeter — works on any concrete type.
func welcome(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    welcome(English{Name: "Alice"})
    welcome(Klingon{Name: "Worf"})
}

4 · Type assertions and type switches

Sometimes you have an interface value and want the concrete type back. Two tools: the type assertion (when you know the type) and the type switch (when you might have one of several).

go main.go · type assertion + type switch
package main

import "fmt"

func main() {
    var v interface{} = "hello"

    // Type assertion — single-arrow form (panics if wrong)
    s := v.(string)
    fmt.Println(s)

    // Comma-ok form — safe
    if n, ok := v.(int); ok {
        fmt.Println("int:", n)
    } else {
        fmt.Println("not an int")
    }

    // Type switch — for many cases
    inspect := func(x interface{}) {
        switch t := x.(type) {
        case nil:        fmt.Println("nil")
        case bool:       fmt.Println("bool:", t)
        case int:        fmt.Println("int:", t)
        case string:     fmt.Println("str:", t)
        case []byte:     fmt.Println("bytes:", string(t))
        case fmt.Stringer: fmt.Println("stringer:", t.String())
        default:         fmt.Printf("other: %T
", x)
        }
    }

    inspect(42)
    inspect("go")
    inspect([]byte("bytes"))
    inspect(3.14)
}

5 · The empty interface — any

interface{} matches everything (every type has zero or more methods). As of Go 1.18, the alias any is the idiomatic spelling.

go main.go · any
package main

import "fmt"

func printAll(items ...any) {
    for i, x := range items {
        fmt.Printf("%d: %T = %v
", i, x, x)
    }
}

func main() {
    printAll(1, "hello", true, 3.14, []int{1, 2, 3})
}
When to use any. JSON unmarshalling into unknown shape, generic logging helpers, plugin systems. Anywhere you'd reach for generics first if they fit — only fall back to any when types really can be different at runtime.

6 · io.Reader / io.Writer — the most important interfaces in Go

go main.go · io.Reader composability
package main

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

// One function works with anything io.Reader-shaped:
// strings.NewReader, *os.File, *bytes.Buffer, *http.Response.Body, ...
func dump(r io.Reader) {
    buf := make([]byte, 1024)
    n, _ := r.Read(buf)
    fmt.Printf("%q
", buf[:n])
}

func main() {
    dump(strings.NewReader("from a string"))

    // Compose: TeeReader wraps a Reader and copies what's read to a Writer.
    var copied strings.Builder
    tee := io.TeeReader(strings.NewReader("hi there"), &copied)
    dump(tee)
    fmt.Println("copied:", copied.String())
}

The whole io package is built on two small interfaces: Read(p []byte) (n int, err error) and Write(p []byte) (n int, err error). Files, network sockets, in-memory buffers, compressed streams — all the same shape. Compose with adapters.

7 · From the wild

go net/http · the entire request handler interface
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

// HandlerFunc adapts a plain function to Handler — pure Go pattern.
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

// The entire HTTP server contract is one method. Middleware = a function
// that wraps a Handler and returns a Handler. Composition is trivial.

func logging(next Handler) Handler {
    return HandlerFunc(func(w ResponseWriter, r *Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}
From the wild: go standard library · BSD-3-Clause

One method. Everything else — routing, middleware, multiplexing — is just plain Go composing that one method. The smallness of the interface is the lever.

8 · Coming from another language?

If you know…The shift
Java / C#No implements keyword. Interfaces are tiny (1-2 methods). No abstract classes — embed if you need shared implementation.
PythonDuck typing, but checked at compile time. fmt.Stringer ≈ implementing __str__.
TypeScriptStructural typing — same shape. TS interfaces describe data shape; Go interfaces describe method shape only.
RustLike a trait without explicit impl Trait for Type — automatic if methods match. No associated types.

9 · Common mistakes

  • The typed-nil gotcha. var e *MyErr; var err error = e; err != nil is TRUE. The interface holds a non-nil type with a nil pointer. Return nil (the untyped) explicitly.
  • Defining interfaces for one implementation. Interfaces in Go are usually defined where they're consumed, not where the type is. "Accept interfaces, return structs."
  • Huge interfaces. A 10-method interface is hard to mock and hard to test. Split into smaller ones; the type still implements all.
  • Mixing value and pointer receivers. If Stringer requires String() on *T, then T doesn't satisfy it — only *T does.
  • Reaching for any too quickly. Since Go 1.18, generics often solve what any used to. Reach for generics first.

10 · Exercises (~15 min)

  1. Implement Stringer. Define type IP4 [4]byte. Add String() string that prints "192.168.1.1". Test with fmt.Println(IP4{192,168,1,1}).
  2. Reader pipeline. Read from a strings.Reader, pipe through io.LimitReader, dump first 10 bytes. The whole chain is interfaces.
  3. The typed-nil trap. Reproduce it. func foo() error returning (*MyErr)(nil). Test if err := foo(); err != nil — surprise.
  4. Type switch dispatch. Given interface{} input, dispatch differently for string / int / []string / map[string]int / default.

11 · When it clicks

  • You define interfaces where you consume them, not where you implement them.
  • You reach for io.Reader/io.Writer for any I/O — even if it's just reading from a string.
  • You can read a function signature and immediately see what it requires of its arguments.
  • You stop being surprised by the typed-nil gotcha — and write defensive code at API boundaries.
Found this useful?