Skip to main content

Command Palette

Search for a command to run...

Go Interfaces

Published
8 min read
B

Hey there! I'm a CS grad, deep into the world of coding. I get a kick out of bringing ideas to life with code – both on the front-end and the back-end side of things. It’s been one heck of a journey, and I’m just getting started.

A bit about me:

💼 Wrapping up my Bachelors in Computer Science this June. Exciting times!

🤔 Right now, I’m juggling my love for front-end, back-end, a tad bit of machine learning, and the wonders of automation.

💬 Got a question or just want to chat about the latest tech trend? Don’t hesitate to reach out.

📫 Connect with me via LinkedIn or email. Just look for those icons above.

⚡ Little fun fact: I crafted my first 'Hello World' when I was 13. It's been a wild ride since!

Go Interfaces: The Shape of Behavior

The Coffee Shop Revelation

Imagine walking into a coffee shop. You don't care if the barista is human, a robot, or a highly trained octopus—as long as they can make coffee. This is exactly how Go interfaces work: they care about what something can do, not what it is.

In most programming explanations, interfaces are described as "contracts" or "abstract types." But that's like explaining water as "H₂O molecules in liquid state"—technically correct but missing the intuitive essence. Let's rebuild your understanding from scratch.

Interfaces Are Shapes, Not Boxes

Traditional object-oriented languages treat types like boxes with labels. You have a "Dog box" and a "Cat box," and you need to explicitly say "this box fits inside the Animal box." Go flips this completely.

In Go, interfaces are more like cookie cutters. They define a shape, and anything that fits that shape can pass through. You never explicitly declare "I implement this interface"—if you fit, you fit.

type Speaker interface {
    Speak() string
}

type Dog struct {
    name string
}

func (d Dog) Speak() string {
    return "Woof!"
}

type Robot struct {
    model string
}

func (r Robot) Speak() string {
    return "Beep boop"
}

// Both Dog and Robot are Speakers, not because we declared it,
// but because they have the right shape (the Speak method)

The Zero-Declaration Principle

Here's what makes Go interfaces revolutionary: you never write "implements." This isn't just syntactic sugar—it's a fundamental philosophy shift.

Consider this scenario: You're using a third-party package that defines a Logger type. Later, you want to use a different logging library that expects a LogWriter interface. In most languages, you're stuck. But in Go:

// Package A (you don't control this)
type FileLogger struct {
    filepath string
}

func (f FileLogger) Write(message string) {
    // writes to file
}

// Package B (you don't control this either)
type LogWriter interface {
    Write(string)
}

func ProcessWithLogging(lw LogWriter) {
    lw.Write("Processing started")
}

// Your code (this just works!)
logger := FileLogger{filepath: "/var/log/app.log"}
ProcessWithLogging(logger)  // FileLogger automatically satisfies LogWriter

The FileLogger was created without any knowledge of LogWriter, yet they work together perfectly. This is like discovering that your phone charger from 2018 works perfectly with a power bank invented in 2024—not because they coordinated, but because they share the same shape.

The Interface{} Paradox

The empty interface interface{} (or any in modern Go) is simultaneously the most powerful and most dangerous type in Go. It's like a shape with no edges—everything fits.

func PrintAnything(thing interface{}) {
    fmt.Println(thing)
}

PrintAnything(42)
PrintAnything("hello")
PrintAnything([]int{1, 2, 3})
PrintAnything(Dog{name: "Rex"})

But here's the paradox: the moment you accept everything, you know nothing. It's like having a universal key that opens every door but doesn't tell you what's behind any of them.

Interface Composition: Building Complex Shapes

Go interfaces can embed other interfaces, creating composite shapes. This isn't inheritance—it's more like combining cookie cutters:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type ReadWriter interface {
    Reader
    Writer
}

// This is not "ReadWriter extends Reader and Writer"
// It's "ReadWriter is the shape that fits both Reader and Writer shapes"

Think of it as overlapping Venn diagrams. A ReadWriter isn't a special kind of Reader or Writer—it's anything that exists in the overlap where both shapes coincide.

The Implicit State Machine

Interfaces enable a pattern I call the "implicit state machine." Watch how interfaces can represent different states of the same conceptual object:

type Document interface {
    Content() string
}

type EditableDocument interface {
    Document
    Update(content string)
}

type PublishableDocument interface {
    Document
    Publish() error
}

type Draft struct {
    content string
}

func (d *Draft) Content() string {
    return d.content
}

func (d *Draft) Update(content string) {
    d.content = content
}

type Published struct {
    content string
    url     string
}

func (p Published) Content() string {
    return p.content
}

func (p Published) Publish() error {
    return errors.New("already published")
}

// A Draft is an EditableDocument
// A Published is a PublishableDocument
// Both are Documents
// This creates a compile-time enforced state machine

Your document naturally transitions through states, and the compiler ensures you can't call Update() on a published document. No runtime checks needed.

The Pointer Receiver Trap

Here's a subtle gotcha that trips up even experienced Go developers:

type Counter interface {
    Increment()
    Value() int
}

type MyCounter struct {
    count int
}

func (c *MyCounter) Increment() {
    c.count++
}

func (c MyCounter) Value() int {
    return c.count
}

var c Counter
c = MyCounter{count: 0}  // COMPILE ERROR!
c = &MyCounter{count: 0} // This works

Why? Because *MyCounter implements Counter, not MyCounter. The pointer type and value type are different citizens in Go's type system. This isn't a bug—it's forcing you to think about whether your interface represents something that can be copied (value receiver) or something with identity (pointer receiver).

Interface Segregation Without the Principle

The Interface Segregation Principle says "clients shouldn't depend on interfaces they don't use." Go naturally encourages this without you trying:

// Instead of this monolith:
type Database interface {
    Query(string) Result
    Insert(Record) error
    Update(Record) error
    Delete(int) error
    BeginTransaction() Transaction
    Backup() error
    Restore(string) error
}

// Go culture creates this:
type Querier interface {
    Query(string) Result
}

type Inserter interface {
    Insert(Record) error
}

type Transactor interface {
    BeginTransaction() Transaction
}

// Functions accept only what they need
func GenerateReport(q Querier) {
    // Only needs to query, doesn't care about insert/update/delete
}

This happens naturally because Go developers quickly learn that smaller interfaces are more reusable. It's like evolution—the interfaces that survive are the ones that do one thing well.

The Testing Superpower

Interfaces make testing almost trivial. You can create test doubles without any mocking framework:

type EmailSender interface {
    Send(to, subject, body string) error
}

// Production implementation
type SMTPEmailSender struct {
    host string
}

func (s SMTPEmailSender) Send(to, subject, body string) error {
    // Actually sends email via SMTP
}

// Test implementation
type RecordingEmailSender struct {
    Sent []string
}

func (r *RecordingEmailSender) Send(to, subject, body string) error {
    r.Sent = append(r.Sent, to)
    return nil
}

// Your function doesn't care which one it gets
func NotifyUser(email EmailSender, userAddr string) {
    email.Send(userAddr, "Notification", "Something happened")
}

No mocking framework, no code generation, no reflection magic—just plain Go code.

The Interface Witness Pattern

Here's an advanced pattern that ensures at compile-time that your types implement interfaces:

type Stringer interface {
    String() string
}

type MyType struct{}

func (m MyType) String() string {
    return "MyType"
}

// This line ensures MyType implements Stringer at compile time
var _ Stringer = MyType{}  // or = (*MyType)(nil) for pointer receivers

This "witness" line will fail to compile if MyType doesn't implement Stringer. It's like a unit test that runs at compile time.

Real-World Example: The Strategy Pattern Without the Pattern

Traditional Strategy pattern requires explicit interface declaration and often a context class. Go makes it invisible:

type PaymentProcessor func(amount float64) error

func ProcessOrder(amount float64, processor PaymentProcessor) error {
    // Validate amount
    if amount <= 0 {
        return errors.New("invalid amount")
    }

    // Process payment using whatever strategy was passed
    return processor(amount)
}

// Different strategies
func CreditCard(amount float64) error {
    fmt.Printf("Processing $%.2f via credit card\n", amount)
    return nil
}

func PayPal(amount float64) error {
    fmt.Printf("Processing $%.2f via PayPal\n", amount)
    return nil
}

func Bitcoin(amount float64) error {
    fmt.Printf("Processing %.8f BTC\n", amount/50000)
    return nil
}

// Usage
ProcessOrder(99.99, CreditCard)
ProcessOrder(99.99, PayPal)
ProcessOrder(99.99, Bitcoin)

The strategy pattern emerges naturally from Go's type system. No need for abstract base classes or explicit interface implementations.

The io.Reader/Writer Ecosystem

The greatest success story of Go interfaces is the io.Reader and io.Writer. These two simple interfaces:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

Power an entire ecosystem. Files, network connections, buffers, compression, encryption—they all speak this common language. It's like how USB became universal not by being complex, but by being simple and consistent.

Performance Implications: The Hidden Cost

Interfaces aren't free. Every interface call involves a virtual dispatch:

type Adder interface {
    Add(int, int) int
}

type SimpleAdder struct{}

func (SimpleAdder) Add(a, b int) int {
    return a + b
}

// Direct call: can be inlined
adder := SimpleAdder{}
result := adder.Add(1, 2)  // Fast

// Interface call: cannot be inlined
var adderInterface Adder = SimpleAdder{}
result = adderInterface.Add(1, 2)  // Slightly slower

For hot paths processing millions of operations, this matters. For everything else, the flexibility is worth the nanoseconds.

The Philosophical Core

Go interfaces embody a philosophy: describe behavior, not identity. It's duck typing with compile-time safety. It's the realization that in software, what something does matters more than what it is.

This creates a unique form of polymorphism. Not the hierarchical polymorphism of class inheritance, but a lateral polymorphism where unrelated types can play the same role simply by exhibiting the same behavior.

Common Pitfalls and How to Avoid Them

The nil interface trap:

var r io.Reader
var f *os.File  // f is nil

r = f  // r is not nil! It has a type (*os.File) but nil value

if r == nil {
    // This won't execute, even though f was nil
}

The interface pollution anti-pattern: Don't create interfaces preemptively. Start with concrete types, extract interfaces when you need them for testing or when you have multiple implementations.

The Accept Interfaces, Return Structs principle: Functions should accept interfaces (be flexible about input) but return concrete types (be specific about output). This maximizes flexibility for callers.

Conclusion: Thinking in Shapes

Go interfaces aren't just a language feature—they're a different way of thinking about types and behavior. Instead of asking "what is this thing?", Go asks "what can this thing do?"

Once you internalize this, you stop thinking in hierarchies and start thinking in capabilities. You stop creating elaborate type taxonomies and start defining minimal behavior contracts. Your code becomes more flexible not through complexity, but through simplicity.

The next time you write Go, don't think about interfaces as contracts to implement. Think of them as shapes that your types naturally fit into. Let the compiler discover the relationships that emerge from behavior, rather than declaring relationships that constrain behavior.

This is the Go way: implicit, minimal, and surprisingly powerful.