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.