Understanding Interfaces
Why Interfaces Matters
The Problem: Tightly-coupled code with concrete types is hard to test and impossible to swap out.
The Solution: Go interfaces are satisfied implicitly — any type with the right methods is automatically compatible. Small interfaces (`io.Reader`, `error`) compose into rich systems without inheritance.
Real Impact: Idiomatic Go uses many small interfaces defined at the consumer rather than monolithic ones at the producer.
Real-World Analogy
Think of an interface as a job description on a noticeboard:
- Interface = the job description — what skills are required
- Concrete type = the candidate — has skills regardless of where they learned them
- Implicit satisfaction = no application form — if you have the skills, you're hired
- Empty interface (any) = 'we'll hire anyone' — only used at boundaries
- Type assertion = a skills test — 'are you specifically a plumber under that uniform?'
Duck Typing and Implicit Satisfaction
Go's interfaces are satisfied implicitly - there's no "implements" keyword. If a type has all the methods an interface requires, it satisfies that interface. This is known as structural typing or duck typing: "If it walks like a duck and quacks like a duck, it's a duck."
Interface Principles
- Implicit satisfaction: No explicit declaration needed
- Small interfaces: The bigger the interface, the weaker the abstraction
- Accept interfaces, return structs: Maximize flexibility
- Interface segregation: Many small interfaces > one large interface
Interface Internals
An interface value consists of two components: a type and a value. Understanding this structure is crucial for avoiding nil interface gotchas and properly using type assertions.
// Interface internals (conceptual)
type iface struct {
tab *itab // Type information
data unsafe.Pointer // Pointer to actual data
}
type itab struct {
inter *interfacetype // Interface type
_type *_type // Concrete type
fun [1]uintptr // Method table
}
Interface Basics
Defining and Implementing Interfaces
Interfaces define contracts that types can satisfy. They specify what methods a type must have but not how those methods are implemented.
// Interface definition
type Writer interface {
Write([]byte) (int, error)
}
type Reader interface {
Read([]byte) (int, error)
}
// Composite interface
type ReadWriter interface {
Reader
Writer
}
// Custom implementation
type MyWriter struct {
data []byte
}
func (w *MyWriter) Write(p []byte) (int, error) {
w.data = append(w.data, p...)
return len(p), nil
}
// MyWriter now implements Writer interface
var w Writer = &MyWriter{}
// Multiple interface satisfaction
type Buffer struct {
data []byte
}
func (b *Buffer) Read(p []byte) (int, error) {
n := copy(p, b.data)
b.data = b.data[n:]
return n, nil
}
func (b *Buffer) Write(p []byte) (int, error) {
b.data = append(b.data, p...)
return len(p), nil
}
// Buffer implements both Reader and Writer
var rw ReadWriter = &Buffer{}
The Empty Interface
The empty interface interface{} (or any in Go 1.18+) is satisfied
by all types. It's Go's way of representing "any type" but should be used sparingly.
⚠️ Empty Interface Cautions
- Loss of type safety - requires type assertions
- Runtime panics possible with incorrect assertions
- Makes code harder to understand and maintain
- Use only when truly necessary (e.g., JSON unmarshaling)
// Empty interface usage
func PrintAnything(v interface{}) {
fmt.Printf("Type: %T, Value: %v\n", v, v)
}
PrintAnything(42) // int
PrintAnything("hello") // string
PrintAnything([]int{1,2,3}) // slice
// Container for any type
type Container struct {
items []interface{}
}
func (c *Container) Add(item interface{}) {
c.items = append(c.items, item)
}
// Go 1.18+ alias
type any = interface{}
func Process(data any) {
// Process any type
}
Type Assertions and Type Switches
Type Assertions
Type assertions extract the concrete value from an interface. They can be used with a single return value (panics on failure) or two return values (safe).
// Type assertion basics
var i interface{} = "hello"
// Unsafe assertion (panics if wrong type)
s := i.(string)
fmt.Println(s) // "hello"
// Safe assertion with ok check
s, ok := i.(string)
if ok {
fmt.Printf("String: %s\n", s)
} else {
fmt.Println("Not a string")
}
// Failed assertion
n, ok := i.(int)
if !ok {
fmt.Println("Not an int") // This executes
}
// Asserting to interface type
var w io.Writer = &bytes.Buffer{}
rw, ok := w.(io.ReadWriter)
if ok {
fmt.Println("Also implements Reader")
}
Type Switches
Type switches are like regular switches but operate on types rather than values. They're cleaner than multiple type assertions for handling multiple possible types.
// Type switch example
func describe(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Integer: %d\n", v)
case string:
fmt.Printf("String: %q\n", v)
case bool:
fmt.Printf("Boolean: %t\n", v)
case []int:
fmt.Printf("Slice of ints: %v\n", v)
case nil:
fmt.Println("nil value")
default:
fmt.Printf("Unknown type: %T\n", v)
}
}
// Multiple types in case
func process(i interface{}) {
switch i.(type) {
case int, int32, int64:
fmt.Println("Integer type")
case float32, float64:
fmt.Println("Float type")
case string:
fmt.Println("String type")
}
}
Common Interface Patterns
Standard Library Interfaces
Go's standard library defines many small, focused interfaces. Understanding and using these interfaces makes your code more idiomatic and interoperable.
// io.Reader - fundamental input interface
type Reader interface {
Read(p []byte) (n int, err error)
}
// io.Writer - fundamental output interface
type Writer interface {
Write(p []byte) (n int, err error)
}
// io.Closer - resource cleanup
type Closer interface {
Close() error
}
// fmt.Stringer - custom string representation
type Stringer interface {
String() string
}
// error - error handling
type error interface {
Error() string
}
// sort.Interface - sorting collections
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
// Custom type implementing multiple interfaces
type LogFile struct {
file *os.File
}
func (l *LogFile) Write(p []byte) (int, error) {
return l.file.Write(p)
}
func (l *LogFile) Close() error {
return l.file.Close()
}
func (l *LogFile) String() string {
return l.file.Name()
}
// LogFile implements Writer, Closer, and Stringer
Interface Composition
Interfaces can embed other interfaces, creating larger interfaces from smaller ones. This promotes interface segregation and reusability.
// Composing interfaces
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
type ReadWriteSeeker interface {
Reader
Writer
Seeker
}
// Custom composite interfaces
type Storage interface {
Save(key string, data []byte) error
Load(key string) ([]byte, error)
}
type CachedStorage interface {
Storage
InvalidateCache(key string)
ClearCache()
}
Design Patterns with Interfaces
Strategy Pattern
The strategy pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable through interfaces.
// Strategy pattern
type PaymentStrategy interface {
Pay(amount float64) error
ValidateDetails() error
}
type CreditCard struct {
Number string
CVV string
}
func (c *CreditCard) Pay(amount float64) error {
fmt.Printf("Paying $%.2f with credit card\n", amount)
return nil
}
func (c *CreditCard) ValidateDetails() error {
if len(c.Number) != 16 {
return fmt.Errorf("invalid card number")
}
return nil
}
type PayPal struct {
Email string
}
func (p *PayPal) Pay(amount float64) error {
fmt.Printf("Paying $%.2f via PayPal\n", amount)
return nil
}
func (p *PayPal) ValidateDetails() error {
if !strings.Contains(p.Email, "@") {
return fmt.Errorf("invalid email")
}
return nil
}
// Context using strategy
type PaymentProcessor struct {
strategy PaymentStrategy
}
func (p *PaymentProcessor) ProcessPayment(amount float64) error {
if err := p.strategy.ValidateDetails(); err != nil {
return err
}
return p.strategy.Pay(amount)
}
Dependency Injection
Interfaces enable dependency injection, making code more testable and flexible by depending on abstractions rather than concrete implementations.
// Dependency injection with interfaces
type Logger interface {
Log(message string)
}
type Database interface {
Query(query string) ([]Row, error)
Execute(cmd string) error
}
type Service struct {
logger Logger
db Database
}
func NewService(logger Logger, db Database) *Service {
return &Service{
logger: logger,
db: db,
}
}
func (s *Service) ProcessOrder(orderID string) error {
s.logger.Log("Processing order: " + orderID)
rows, err := s.db.Query("SELECT * FROM orders WHERE id = " + orderID)
if err != nil {
s.logger.Log("Error querying database: " + err.Error())
return err
}
// Process rows...
return nil
}
// Testing with mocks
type MockLogger struct {
messages []string
}
func (m *MockLogger) Log(message string) {
m.messages = append(m.messages, message)
}
type MockDatabase struct {
queryFunc func(string) ([]Row, error)
}
func (m *MockDatabase) Query(query string) ([]Row, error) {
return m.queryFunc(query)
}
func (m *MockDatabase) Execute(cmd string) error {
return nil
}
Interface Best Practices
Interface Design Guidelines
- Keep interfaces small: 1-3 methods is ideal
- Define interfaces where used: Not where implemented
- Accept interfaces, return structs: Maximize flexibility
- Don't export interfaces prematurely: Start concrete, abstract when needed
- Name interfaces with -er suffix: Reader, Writer, Closer
- Document interface contracts: Behavior expectations
| Pattern | Good | Bad | Reason |
|---|---|---|---|
| Interface Size | 1-3 methods | 10+ methods | Smaller interfaces are more flexible |
| Definition Location | Consumer package | Producer package | Consumer knows what it needs |
| Parameter Type | Interface | Concrete type | Accept the minimum required |
| Return Type | Concrete type | Interface | Return the maximum information |
| Naming | Reader, Stringer | IReader, ReaderInterface | Go convention avoids prefixes/suffixes |
Common Pitfalls
Nil Interface Values
A common gotcha is that an interface value that holds a nil concrete value is itself non-nil. This can lead to unexpected behavior.
⚠️ Interface Gotchas
- Nil interface != nil concrete: Interface with nil pointer is not nil
- Type assertion panics: Always use two-value form for safety
- Interface comparison: Two interfaces are equal if types and values match
- Pointer vs value receivers: Affects interface satisfaction
// Nil interface gotcha
type MyError struct {
Message string
}
func (e *MyError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func riskyFunction() error {
var e *MyError = nil
return e // Returns non-nil interface!
}
func main() {
err := riskyFunction()
if err != nil {
fmt.Println("Got error:", err) // This executes!
}
}
// Correct approach
func safeFunction() error {
var e *MyError = nil
if e == nil {
return nil // Return nil interface
}
return e
}
// Interface comparison
var a, b interface{}
a = 42
b = 42
fmt.Println(a == b) // true
a = []int{1}
b = []int{1}
// fmt.Println(a == b) // Panic! Slices aren't comparable
Practice Exercises
Exercise 1: Plugin System
Design a plugin system using interfaces that allows dynamic loading of different processors.
Exercise 2: Mock Testing Framework
Create a simple mocking framework using interfaces for unit testing.
Exercise 3: Adapter Pattern
Implement adapters to make incompatible interfaces work together.
Exercise 4: Pipeline Processing
Build a data pipeline using interfaces for transformers and filters.