Go Interfaces: Polymorphism in Go

Go's approach to polymorphism through interfaces is fundamentally different from class-based languages like Java or C#. Understanding this distinction is critical to writing idiomatic Go code....

Key Insights

  • Go interfaces are satisfied implicitly—types don’t declare they implement an interface, they just do if their method signatures match, enabling powerful decoupling and composition patterns.
  • Small, focused interfaces (often single-method) are idiomatic in Go and lead to more flexible, testable code than large interface hierarchies common in other languages.
  • The empty interface interface{} accepts any type but should be used sparingly; prefer concrete types or specific interfaces, and use type assertions carefully to avoid runtime panics.

Go’s approach to polymorphism through interfaces is fundamentally different from class-based languages like Java or C#. Understanding this distinction is critical to writing idiomatic Go code. Interfaces in Go are implicit, lightweight, and encourage composition over inheritance—a design philosophy that leads to more maintainable and testable systems.

Understanding Go’s Interface Model

In Go, an interface is a type that specifies a set of method signatures. Any type that implements those methods satisfies the interface automatically—no explicit declaration required. This is often called “structural typing” or “duck typing”: if it walks like a duck and quacks like a duck, it’s a duck.

type Shape interface {
    Area() float64
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return 3.14159 * c.Radius * c.Radius
}

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

Notice that neither Circle nor Rectangle declares that it implements Shape. They just do, because they have an Area() method with the correct signature. This implicit satisfaction is powerful—you can define interfaces for types you don’t own, and types can satisfy interfaces that didn’t exist when they were written.

Interface Basics and the Empty Interface

An interface defines behavior, not data. The method set of an interface is the contract that implementing types must fulfill. Go’s type system checks at compile time whether a type satisfies an interface.

The empty interface, interface{} (or any in Go 1.18+), has zero methods, so every type satisfies it. This makes it Go’s way of representing “any type,” but it comes at the cost of type safety.

type Triangle struct {
    Base, Height float64
}

func (t Triangle) Area() float64 {
    return 0.5 * t.Base * t.Height
}

func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Rectangle{Width: 4, Height: 6},
        Triangle{Base: 3, Height: 4},
    }
    
    for _, shape := range shapes {
        fmt.Printf("Area: %.2f\n", shape.Area())
    }
}

Here, we store different concrete types in a slice of Shape interfaces. The compiler ensures each type has the required Area() method. This is polymorphism in action—one interface type, multiple implementations.

Polymorphic Functions and Behavior

The real power of interfaces emerges when writing functions that operate on interface types rather than concrete types. This enables code reuse and makes your functions work with any type that satisfies the interface, including types that haven’t been written yet.

func PrintArea(s Shape) {
    fmt.Printf("The area is: %.2f\n", s.Area())
}

func TotalArea(shapes []Shape) float64 {
    var total float64
    for _, shape := range shapes {
        total += shape.Area()
    }
    return total
}

func main() {
    c := Circle{Radius: 3}
    r := Rectangle{Width: 5, Height: 2}
    
    PrintArea(c)  // Works with Circle
    PrintArea(r)  // Works with Rectangle
    
    shapes := []Shape{c, r}
    fmt.Printf("Total area: %.2f\n", TotalArea(shapes))
}

This approach inverts the typical object-oriented paradigm. Instead of types declaring what interfaces they implement, interfaces are defined by consumers based on what behavior they need. This consumer-driven design leads to minimal, focused interfaces.

Standard Library Patterns

The Go standard library extensively uses small, composable interfaces. Understanding these patterns is essential for writing idiomatic Go.

type Logger struct {
    prefix string
}

// Implement io.Writer interface
func (l Logger) Write(p []byte) (n int, err error) {
    fmt.Printf("[%s] %s", l.prefix, string(p))
    return len(p), nil
}

// Implement fmt.Stringer interface
type Point struct {
    X, Y int
}

func (p Point) String() string {
    return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}

func main() {
    logger := Logger{prefix: "INFO"}
    fmt.Fprintf(logger, "Application started\n")
    
    p := Point{X: 10, Y: 20}
    fmt.Println(p)  // Automatically calls String() method
}

Interface embedding allows you to compose larger interfaces from smaller ones:

type ReadWriter interface {
    io.Reader
    io.Writer
}

type ReadWriteCloser interface {
    io.Reader
    io.Writer
    io.Closer
}

This composition pattern is preferred over creating large, monolithic interfaces. It adheres to the Interface Segregation Principle—clients shouldn’t depend on methods they don’t use.

Type Assertions and Type Switches

Sometimes you need to access the concrete type underlying an interface value. Type assertions provide this mechanism, but they can panic if the assertion is incorrect.

func DescribeShape(s Shape) {
    // Safe type assertion with comma-ok idiom
    if circle, ok := s.(Circle); ok {
        fmt.Printf("Circle with radius %.2f\n", circle.Radius)
        return
    }
    
    if rect, ok := s.(Rectangle); ok {
        fmt.Printf("Rectangle: %.2f x %.2f\n", rect.Width, rect.Height)
        return
    }
    
    fmt.Println("Unknown shape type")
}

func ProcessShape(s Shape) {
    // Type switch for multiple types
    switch v := s.(type) {
    case Circle:
        fmt.Printf("Processing circle with radius %.2f\n", v.Radius)
    case Rectangle:
        fmt.Printf("Processing rectangle: %.2f x %.2f\n", v.Width, v.Height)
    case Triangle:
        fmt.Printf("Processing triangle: base=%.2f, height=%.2f\n", v.Base, v.Height)
    default:
        fmt.Printf("Unknown shape type: %T\n", v)
    }
}

Use type assertions sparingly. If you find yourself frequently checking concrete types, it might indicate that your interface is too broad or that you’re not leveraging polymorphism effectively.

Best Practices and Common Pitfalls

Keep interfaces small. The most common interface size in the Go standard library is one method. Single-method interfaces are easier to implement, test, and compose.

Accept interfaces, return concrete types. This principle maximizes flexibility for callers while maintaining clarity about what your functions return.

// Good: accepts interface, returns concrete type
func NewCircle(r io.Reader) (*Circle, error) {
    // Read circle data from any io.Reader
    var radius float64
    _, err := fmt.Fscanf(r, "%f", &radius)
    if err != nil {
        return nil, err
    }
    return &Circle{Radius: radius}, nil
}

Beware the nil interface trap. An interface value is nil only if both its type and value are nil. This causes subtle bugs:

func returnsError() error {
    var err *MyError = nil  // typed nil
    return err  // interface is not nil!
}

func main() {
    if err := returnsError(); err != nil {
        fmt.Println("Error occurred")  // This prints!
    }
}

The fix is to return an untyped nil or ensure you return concrete types only when they’re non-nil.

Real-World Application: Dependency Injection for Testing

Interfaces shine in dependency injection and testing. By depending on interfaces rather than concrete types, you can easily swap implementations for testing.

// Payment processor interface
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Real implementation
type StripeProcessor struct {
    apiKey string
}

func (s *StripeProcessor) ProcessPayment(amount float64) error {
    // Real Stripe API call
    fmt.Printf("Processing $%.2f via Stripe\n", amount)
    return nil
}

// Mock for testing
type MockProcessor struct {
    ProcessedAmount float64
    ShouldFail      bool
}

func (m *MockProcessor) ProcessPayment(amount float64) error {
    m.ProcessedAmount = amount
    if m.ShouldFail {
        return fmt.Errorf("payment failed")
    }
    fmt.Printf("Mock: processed $%.2f\n", amount)
    return nil
}

// Business logic depends on interface
type OrderService struct {
    processor PaymentProcessor
}

func (o *OrderService) PlaceOrder(amount float64) error {
    if amount <= 0 {
        return fmt.Errorf("invalid amount")
    }
    return o.processor.ProcessPayment(amount)
}

func main() {
    // Production code uses real processor
    prodService := &OrderService{
        processor: &StripeProcessor{apiKey: "sk_live_..."},
    }
    prodService.PlaceOrder(99.99)
    
    // Test code uses mock
    mock := &MockProcessor{}
    testService := &OrderService{processor: mock}
    testService.PlaceOrder(49.99)
    
    fmt.Printf("Mock recorded payment of $%.2f\n", mock.ProcessedAmount)
}

This pattern makes your code testable without complex mocking frameworks. The OrderService doesn’t know or care whether it’s using a real payment processor or a mock—it just calls the interface methods.

Conclusion

Go’s interface system is deceptively simple but remarkably powerful. By embracing implicit satisfaction, small interfaces, and composition, you can write flexible, testable code that’s easy to extend. The key is to think in terms of behavior rather than inheritance hierarchies. Define interfaces at the point of use, keep them minimal, and let the compiler ensure your types satisfy the contracts they need. This approach leads to loosely coupled systems where components can be easily swapped, tested, and evolved independently.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.