GUIDE
GoTutorialBackendSystems Programming

Module 3 of 6

Module 3: Interfaces & Implicit Satisfaction

Master Go's structural typing — how implicit interface satisfaction enables extensibility, interface segregation, and the nil interface trap.

7 min read
by Faisal Ahmed Sifat

Module 3: Interfaces & Implicit Satisfaction

The mental model shift

If you come from Java, C#, or TypeScript, interfaces in Go will feel like someone removed all the guardrails. You'll write code that compiles, look at it, and think "but... how does the compiler know?"

The answer is the most radical design decision in Go: structural typing.

In TypeScript, you write class Foo implements Bar. In Java, same thing. The type declares what interfaces it satisfies. This is nominal typing — the relationship is explicit, named, and checked by the compiler.

In Go, you write nothing. If your type has the methods the interface requires, it satisfies the interface. Period. No implements keyword. No declaration. The compiler checks it, but you never have to state it.

Here's why this matters, and it's bigger than you think.

You can define an interface in the consumer package, not the provider package. This is backwards from most OO languages. In Java, the interface lives with the implementation. In Go, the interface lives with the code that uses it. You define type Reader interface { Read([]byte) (int, error) } wherever you need to read bytes — and suddenly every type with a Read method works with your code. You didn't have to modify anyone else's package.

This is how Go achieves extensibility without inheritance. No base classes. No mixins. No monkey patching. You define a small interface for what you need, and the world plugs into it.

The standard library is the proof. io.Reader is one method. io.Writer is one method. You can compose them into io.ReadWriter. These three interfaces power the entire I/O ecosystem — files, network connections, HTTP bodies, gzip streams, cipher wrappers, everything. Each implementation is focused. Each interface is minimal. They compose like LEGO.

Key concepts

  • Satisfaction is implicit — method set matches, that's it. The compiler verifies at compile time.
  • Interface values are (type, pointer) pairs internally. This is the source of the nil interface trap (see Drill 3).
  • interface{} (or any since Go 1.18) accepts anything. Use type assertions v.(T) or type switches to extract concrete values.
  • Embedding interfaces composes them: type ReadWriter interface { Reader; Writer }.
  • Accept interfaces, return concrete types. This design rule is so important it's practically Go law. Functions should be liberal in what they accept and specific in what they return.

Drill 1 — The io.Reader contract

Let's build something that satisfies io.Reader without ever mentioning it:

package main
 
import (
    "fmt"
    "io"
    "strings"
)
 
// ROT13Reader wraps any io.Reader and applies ROT13 substitution
// Notice: no "implements io.Reader" anywhere
type ROT13Reader struct {
    r io.Reader
}
 
func (r ROT13Reader) Read(p []byte) (n int, err error) {
    n, err = r.r.Read(p)
    for i := 0; i < n; i++ {
        c := p[i]
        switch {
        case c >= 'A' && c <= 'Z':
            p[i] = 'A' + (c-'A'+13)%26
        case c >= 'a' && c <= 'z':
            p[i] = 'a' + (c-'a'+13)%26
        }
    }
    return
}
 
func main() {
    r := ROT13Reader{strings.NewReader("Hello, World!")}
    bs, _ := io.ReadAll(r)
    fmt.Println(string(bs)) // Uryyb, Jbeyq!
}

Here's what just happened: We built a type with a Read method. It now works with anything that accepts io.Readerio.ReadAll, bufio.NewScanner, json.NewDecoder, HTTP response bodies, file handles, gzip readers. We didn't import anything special. We didn't declare anything. We just satisfied the contract with a method.

This is the moment where Go interfaces start to feel like magic. You write one method and gain compatibility with an entire ecosystem.

Drill 2 — Interface segregation in practice

Here's a trap I fell into early on, and one you'll recognize from OO languages:

// Bad: "I'll put everything someone might need in one interface"
type BadStorage interface {
    Get(key string) ([]byte, error)
    Set(key string, val []byte) error
    Delete(key string) error
    Flush() error
    Stats() map[string]int
}

This looks reasonable, right? It isn't. Every consumer of this interface now depends on every method. You can't use it with a read-only cache. You can't mock it without implementing five methods. You've coupled all consumers together.

Here's the Go way:

type Getter interface {
    Get(key string) ([]byte, error)
}
 
type Setter interface {
    Set(key string, val []byte) error
}
 
// Compose them when you need both
type ReadWriter interface {
    Getter
    Setter
}
 
// Our implementation
type MemStore struct {
    data map[string][]byte
}
 
func (m *MemStore) Get(key string) ([]byte, error) { /* ... */ }
func (m *MemStore) Set(key string, val []byte) error { /* ... */ }
 
// This function only needs to read — takes the minimal interface
func printValue(g Getter, key string) {
    v, err := g.Get(key)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("%s = %s\n", key, v)
}

The rule: define interfaces where they're used, not where they're implemented. Make them as small as possible — often one method. Compose them for complex needs. This isn't just style. It's the difference between a codebase where you can test anything in isolation and one where every test drags in the world.

Drill 3 — The nil interface trap

This one will bite you. It bites everyone. It bit me twice before it stuck:

package main
 
import "fmt"
 
type MyError struct {
    msg string
}
 
func (e *MyError) Error() string { return e.msg }
 
// This function LOOKS like it returns nil when there's no error...
func buggy() error {
    var err *MyError // typed nil pointer — err is (*MyError)(nil)
    // ... some logic that doesn't set err ...
    return err // returns (*MyError)(nil), NOT nil error interface
}
 
// The fix: return untyped nil
func fixed() error {
    var err *MyError
    if err != nil { // this is never true — err is a nil pointer
        return err
    }
    return nil // untyped nil — the error interface itself is nil
}
 
func main() {
    err := buggy()
    fmt.Println("buggy() == nil:", err == nil) // false! And you WILL debug this for 30 minutes
 
    err = fixed()
    fmt.Println("fixed() == nil:", err == nil) // true
}

Why this happens: An interface value in Go is a two-word structure: (type, data). For an interface to be nil, both must be nil. When you return err where err is a *MyError typed nil, the interface value becomes (*MyError, nil). The data pointer is nil, but the type is set — so the interface itself is non-nil.

The fix is always the same: if you have a function that returns an interface type (like error), and you want to return nil, use return nil — not a typed nil variable. If you must use a variable, explicitly check and return nil unconditionally in the nil case.

Capstone — Plugin system via interfaces

Build a CLI that processes text through a configurable pipeline of plugins:

type Plugin interface {
    Name() string
    Process(input string) (string, error)
}

Implement at least 4 plugins: uppercase, trim, prefix{text}, word-count. Build a Pipeline type that chains them. If any plugin errors, the pipeline stops and reports which plugin failed.

The constraints:

  • Plugins are registered by name in a map — no hardcoded switch statement
  • CLI accepts --plugins=uppercase,trim,prefix:hello and runs them in order
  • Pipeline.Run accepts io.Reader and writes to io.Writer — connecting this module to the io.Reader pattern from Drill 1

When you're done, adding a new plugin is a matter of implementing Plugin and adding one line to a registry. That's the power of interfaces done right.