Go Interfaces: Implicit Implementation Done Right
Understand Go interfaces in depth: implicit satisfaction, small interface design, io.Reader and io.Writer, accept interfaces return structs, and type assertions.
What you'll learn
- ✓How implicit interface satisfaction works
- ✓Why small interfaces lead to better designs
- ✓How io.Reader and io.Writer compose across the standard library
- ✓The accept interfaces, return structs guideline
- ✓How to use type assertions and type switches safely
Prerequisites
- •Familiar with [Go functions](/blog/go-functions)
- •Comfortable with [Go variables and types](/blog/go-variables-and-types)
Interfaces are the connective tissue of Go programs. Unlike Java or C#, Go interfaces are satisfied implicitly: any type with the required methods automatically implements the interface, with no implements clause anywhere. This single decision changes how you design APIs and how libraries compose.
Defining an interface
An interface is a set of method signatures. Any type that has those methods satisfies it.
package main
import "fmt"
type Shape interface {
Area() float64
}
type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64 { return r.W * r.H }
type Circle struct{ R float64 }
func (c Circle) Area() float64 { return 3.14159 * c.R * c.R }
func describe(s Shape) {
fmt.Printf("area = %.2f\n", s.Area())
}
func main() {
describe(Rectangle{W: 3, H: 4})
describe(Circle{R: 2})
}
Neither Rectangle nor Circle mentions Shape. They just happen to have an Area() method, so they qualify. This duck typing at compile time decouples implementations from the interfaces they fit into.
Small interfaces win
The Go community has a saying: “The bigger the interface, the weaker the abstraction.” The standard library proves it. io.Reader and io.Writer each declare exactly one method, yet they compose into an enormous ecosystem of files, sockets, buffers, gzip streams, hash functions, and HTTP bodies.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
A function that takes io.Reader accepts a file, a network connection, a string buffer, or a decompressor without knowing or caring which.
package main
import (
"fmt"
"io"
"strings"
)
func wordCount(r io.Reader) (int, error) {
buf := make([]byte, 1024)
count, in := 0, false
for {
n, err := r.Read(buf)
for i := 0; i < n; i++ {
c := buf[i]
if c == ' ' || c == '\n' || c == '\t' {
in = false
} else if !in {
in = true
count++
}
}
if err == io.EOF {
return count, nil
}
if err != nil {
return count, err
}
}
}
func main() {
n, _ := wordCount(strings.NewReader("hello concurrent world"))
fmt.Println(n)
}
The function does not import any concrete type. Tests can pass a strings.Reader; production code can pass an *os.File.
Accept interfaces, return structs
A widely repeated guideline: accept interfaces, return structs. Accepting an interface lets callers supply any compatible type. Returning a concrete struct gives callers the full surface of methods and fields without forcing them through a narrow abstraction.
// Good: input is general, output is specific
func NewBufferedScanner(r io.Reader) *BufferedScanner { /* ... */ return nil }
// Often awkward: callers cannot reach methods unique to the implementation
func NewBufferedScanner(r io.Reader) Scanner { /* ... */ return nil }
There are exceptions, like factories that genuinely need to return different concrete types, but start with the rule and break it only when you have a reason.
Interface values under the hood
An interface value holds two things: a concrete type and a value (or pointer). A nil interface has both parts nil; an interface containing a nil pointer is not equal to nil. This is one of Go’s most common footguns.
package main
import "fmt"
type Logger interface{ Log(string) }
type FileLogger struct{}
func (f *FileLogger) Log(s string) { fmt.Println(s) }
func newLogger(enabled bool) Logger {
var f *FileLogger
if enabled {
f = &FileLogger{}
}
return f // wraps a nil *FileLogger, not a nil interface
}
func main() {
l := newLogger(false)
fmt.Println(l == nil) // false, surprisingly
}
If you want to return a nil interface, return a bare nil from a function whose return type is the interface, not a typed nil pointer.
Type assertions and type switches
When you have an interface value and need the concrete type back, use an assertion:
var r io.Reader = strings.NewReader("hi")
s, ok := r.(*strings.Reader)
if ok {
fmt.Println("got a strings.Reader of length", s.Len())
}
The two-value form prevents a panic if the assertion fails. For more than one possible type, a type switch is cleaner:
func describe(v interface{}) {
switch x := v.(type) {
case int:
fmt.Println("int:", x)
case string:
fmt.Println("string:", x)
case fmt.Stringer:
fmt.Println("stringer:", x.String())
default:
fmt.Println("unknown")
}
}
Use assertions sparingly; they are a sign that you might be undoing an abstraction you just built. Sometimes that is correct (think io.WriterTo optimizations), but reach for them deliberately.
Embedding interfaces
Interfaces compose by embedding other interfaces. io.ReadWriter is just:
type ReadWriter interface {
Reader
Writer
}
Any type that satisfies both Reader and Writer automatically satisfies ReadWriter. This is composition by addition, which keeps individual interfaces minimal while letting you express richer contracts when needed.
Designing your own interfaces
Some practical advice:
- Define interfaces near the consumer, not the implementer. The package that needs the abstraction owns it.
- Start with one method; add only when reality demands.
- Name single-method interfaces by appending
-er:Closer,Stringer,Logger. - Avoid
interface{}(now spelledany) unless you really mean it. Most uses can be replaced by a typed parameter or a small interface.
For larger refactors, the same instincts that helped you split big functions in Go functions apply here: small, single-purpose units compose better than monoliths.
Wrap up
Interfaces in Go are deliberately humble: a list of method signatures, satisfied implicitly. That humility is what makes them powerful. Favor small interfaces, accept them as inputs, and return concrete types whose strengths the caller can fully use. Reach for type assertions only when you need to climb back down out of an abstraction, and lean on embedding to compose larger contracts from focused pieces.