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?

Published Updated