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.
io.Reader, io.Writer,
fmt.Stringer all do exactly one thing.2 · Try it
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
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).
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.
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})
}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
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
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)
})
}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. |
| Python | Duck typing, but checked at compile time. fmt.Stringer ≈ implementing __str__. |
| TypeScript | Structural typing — same shape. TS interfaces describe data shape; Go interfaces describe method shape only. |
| Rust | Like 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 != nilis TRUE. The interface holds a non-nil type with a nil pointer. Returnnil(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
StringerrequiresString()on*T, thenTdoesn't satisfy it — only*Tdoes. - Reaching for
anytoo quickly. Since Go 1.18, generics often solve whatanyused to. Reach for generics first.
10 · Exercises (~15 min)
- Implement Stringer. Define
type IP4 [4]byte. AddString() stringthat prints"192.168.1.1". Test withfmt.Println(IP4{192,168,1,1}). - Reader pipeline. Read from a
strings.Reader, pipe throughio.LimitReader, dump first 10 bytes. The whole chain is interfaces. - The typed-nil trap. Reproduce it.
func foo() errorreturning(*MyErr)(nil). Testif err := foo(); err != nil— surprise. - Type switch dispatch. Given
interface{}input, dispatch differently forstring/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.Writerfor 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.