Skip to content

Interfaces and Polymorphism Intermediate

Introduction

Interfaces are the cornerstone of abstraction in Go. Unlike Java or C#, Go interfaces are satisfied implicitly -- there is no implements keyword. If a type has the right methods, it satisfies the interface, period. This decoupling enables powerful composition, testability, and the kind of duck-typing flexibility that makes Go code remarkably adaptable. The Go proverb "the bigger the interface, the weaker the abstraction" captures the design philosophy: keep interfaces small and focused. Understanding interfaces, their underlying representation as (value, type) pairs, and the subtle nil-interface gotcha is essential for interviews and real-world Go.


Declaring Interfaces

// An interface is a set of method signatures
type Reader interface {
    Read(p []byte) (n int, err error)
}

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

// Any type with a matching Read method satisfies Reader -- no "implements" needed
type FileReader struct {
    path string
}

func (f *FileReader) Read(p []byte) (n int, err error) {
    // ... read from file into p ...
    return len(p), nil
}

// FileReader implicitly satisfies the Reader interface
var r Reader = &FileReader{path: "/tmp/data.txt"}

Implicit Satisfaction

type Stringer interface {
    String() string
}

type User struct {
    Name string
    Age  int
}

func (u User) String() string {
    return fmt.Sprintf("%s (%d)", u.Name, u.Age)
}

// User satisfies Stringer without any declaration
var s Stringer = User{Name: "Alice", Age: 30}
fmt.Println(s) // "Alice (30)"

Compile-Time Check Idiom

To verify a type satisfies an interface at compile time without allocating:

var _ Stringer = (*User)(nil)   // won't compile if User doesn't satisfy Stringer
var _ io.Reader = (*MyReader)(nil)


Empty Interface: any / interface{}

The empty interface has zero methods, so every type satisfies it. Go 1.18 introduced any as an alias for interface{}.

func printAnything(v any) {
    fmt.Println(v)
}

printAnything(42)
printAnything("hello")
printAnything([]int{1, 2, 3})

// Used heavily in the standard library
var m map[string]any // JSON-like dynamic data

Avoid Overusing any

Using any discards type safety. Prefer concrete types or defined interfaces wherever possible. Since Go 1.18, generics often eliminate the need for any.


Interface Composition

Go interfaces are composed by embedding other interfaces -- this is how the standard library builds powerful abstractions from tiny pieces.

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

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

type Closer interface {
    Close() error
}

// Composed interfaces
type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

// os.File satisfies ReadWriteCloser because it has Read, Write, and Close methods
var rwc ReadWriteCloser = os.Stdout

Type Assertions

Type assertions extract the concrete type from an interface value.

var r Reader = &FileReader{path: "/tmp/data.txt"}

// Single-value form -- panics if wrong type
fr := r.(*FileReader)
fmt.Println(fr.path)

// Comma-ok form -- safe, never panics
fr, ok := r.(*FileReader)
if ok {
    fmt.Println("It's a FileReader:", fr.path)
}

// Type switch -- idiomatic for handling multiple possible types
func describe(v any) string {
    switch val := v.(type) {
    case string:
        return "string of length " + strconv.Itoa(len(val))
    case int:
        return "integer: " + strconv.Itoa(val)
    case error:
        return "error: " + val.Error()
    default:
        return fmt.Sprintf("unknown: %T", val)
    }
}

Always Use Comma-Ok Form

The single-value type assertion x := i.(T) panics if i doesn't hold type T. Always use the comma-ok pattern in production code unless you're absolutely certain of the type.


Interface Values: The (Value, Type) Pair

An interface value internally stores two things: a pointer to the concrete value and a pointer to the type descriptor.

var w io.Writer          // (nil, nil) -- truly nil interface
w = os.Stdout            // (*os.File, *os.File type) -- non-nil
w = (*os.File)(nil)      // (nil, *os.File type) -- NOT nil interface!

fmt.Println(w == nil)    // false! The type component is set
┌──────────────────────┐
│   Interface Value     │
├───────────┬──────────┤
│   type    │  value   │
│  *os.File │   nil    │  ← w != nil even though concrete value is nil
└───────────┴──────────┘

Nil Interface vs Nil Concrete Type

This is the most critical interface gotcha in Go and a top interview question.

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func getError(fail bool) error {
    var err *MyError  // nil pointer of type *MyError
    if fail {
        err = &MyError{"something broke"}
    }
    return err  // BUG: returns non-nil interface wrapping a nil *MyError
}

func main() {
    err := getError(false)
    if err != nil {
        fmt.Println("got error:", err) // This RUNS -- err is non-nil!
    }
}

The Nil Interface Trap

An interface is only nil when both its type and value components are nil. Returning a typed nil pointer as an interface value produces a non-nil interface. The fix:

func getError(fail bool) error {
    if fail {
        return &MyError{"something broke"}
    }
    return nil  // return bare nil, not a typed nil pointer
}


The io.Reader / io.Writer Pattern

The io.Reader and io.Writer interfaces are the most important in Go's standard library. Entire ecosystems of composable I/O are built on these tiny interfaces.

// io.Reader: single method, endlessly composable
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Everything reads and writes through these interfaces:
// os.File, bytes.Buffer, strings.Reader, net.Conn, http.Response.Body,
// gzip.Reader, json.Decoder, bufio.Reader, ...

// Copy from any Reader to any Writer
func process(r io.Reader) error {
    buf := make([]byte, 4096)
    for {
        n, err := r.Read(buf)
        if n > 0 {
            fmt.Print(string(buf[:n]))
        }
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
    }
}

// Composability: stack readers like middleware
func processGzipped(r io.Reader) error {
    gr, err := gzip.NewReader(r)
    if err != nil {
        return err
    }
    defer gr.Close()
    return process(gr)  // gr is also an io.Reader
}

Accept Interfaces, Return Structs

This Go proverb guides API design for maximum flexibility.

// GOOD: function accepts an interface -- callers can pass anything that satisfies it
func SaveData(w io.Writer, data []byte) error {
    _, err := w.Write(data)
    return err
}

// GOOD: function returns a concrete struct -- callers know exactly what they get
func NewServer(addr string) *Server {
    return &Server{addr: addr}
}

// Can call SaveData with any Writer
SaveData(os.Stdout, []byte("to terminal"))
SaveData(&buf, []byte("to buffer"))
SaveData(conn, []byte("to network"))

Why This Works

  • Accepting interfaces gives callers flexibility and makes the function easy to test with mocks.
  • Returning structs gives callers access to the full API of the concrete type, and doesn't prematurely constrain future additions.

Common Standard Library Interfaces

// fmt.Stringer -- controls how a type prints with %s and %v
type Stringer interface {
    String() string
}

// error -- the universal error interface
type error interface {
    Error() string
}

// io.Reader / io.Writer -- composable I/O
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}

// io.Closer -- resource cleanup
type Closer interface {
    Close() error
}

// sort.Interface -- enables sort.Sort on any collection
type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

// encoding.BinaryMarshaler / encoding.BinaryUnmarshaler
// json.Marshaler / json.Unmarshaler
// http.Handler
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Quick Reference

Concept Syntax / Rule Notes
Declare interface type I interface { Method() } Set of method signatures
Implicit satisfaction No implements keyword If methods match, it satisfies
Empty interface any or interface{} Satisfied by every type
Compose interfaces type RW interface { Reader; Writer } Embed interfaces inside interfaces
Type assertion (safe) v, ok := i.(T) Returns (zero, false) if wrong type
Type switch switch v := i.(type) { ... } Branch on concrete type
Interface value (value, type) pair Both must be nil for == nil
Nil gotcha Typed nil ≠ nil interface Return bare nil, not typed nil pointer
Accept interfaces func Foo(r io.Reader) Flexible, testable
Return structs func New() *MyStruct Concrete, extensible

Best Practices

  1. Keep interfaces small -- one or two methods is ideal. The io.Reader (1 method) is far more powerful than any 10-method interface
  2. Define interfaces where they're used, not where they're implemented -- the consumer defines what it needs
  3. Accept interfaces, return structs -- maximizes flexibility for callers and testability
  4. Use compile-time interface checks -- var _ Interface = (*Type)(nil) catches drift early
  5. Prefer any over interface{} in Go 1.18+ code for readability
  6. Don't create interfaces preemptively -- wait until you have at least two concrete types or a testing need
  7. Name single-method interfaces with the method name + "er" suffix: Reader, Writer, Closer, Stringer

Common Pitfalls

Nil Interface Trap

Returning a typed nil pointer through an interface makes the interface non-nil. Always return bare nil from functions returning interfaces:

// BAD
func Get() error {
    var err *MyError
    return err  // non-nil interface!
}

// GOOD
func Get() error {
    return nil
}

Fat Interfaces

Defining interfaces with many methods couples consumers tightly to one implementation. Split large interfaces into small, composable ones:

// BAD: 6 methods -- almost impossible to mock or swap
type UserService interface {
    Create(User) error
    Get(id int) (User, error)
    Update(User) error
    Delete(id int) error
    List() ([]User, error)
    Search(q string) ([]User, error)
}

// GOOD: small, focused interfaces
type UserGetter interface { Get(id int) (User, error) }
type UserCreator interface { Create(User) error }

Interface Pollution

Don't define interfaces for every type. If there's only one implementation and no testing need, just use the concrete type. Interfaces add indirection -- earn it.

Unsafe Type Assertion

val := iface.(string)  // panics if iface doesn't hold a string
Always use the comma-ok form in production: val, ok := iface.(string).


Performance Considerations

Scenario Impact
Interface method call Small overhead (~1-2 ns) for indirect dispatch via vtable; negligible in most code
Interface allocation Assigning a concrete value to an interface may cause a heap escape if the value doesn't fit in the interface's inline storage
Type assertion Very fast (~1-2 ns) -- it's a pointer comparison on the type descriptor
Empty interface (any) Same overhead as any interface; the compiler cannot optimize method calls
Generics vs interfaces Generics (Go 1.18+) can be monomorphized, avoiding interface dispatch overhead in hot paths

Escape Analysis

Assigning a value type to an interface often forces a heap allocation because the interface needs a pointer to the value. For hot paths, consider passing concrete types or using generics.


Interview Tips

Interview Tip

When asked "How do interfaces work in Go?", explain: Go uses structural typing -- a type satisfies an interface if it has all the required methods, with no explicit declaration. This enables decoupled design where packages can define interfaces independently of implementations.

Interview Tip

The nil interface question is a top gotcha. Explain: an interface value is a (type, value) pair. It's only nil when both components are nil. A typed nil pointer wrapped in an interface is not nil. Demonstrate with the getError pattern.

Interview Tip

Know the Go proverb: "The bigger the interface, the weaker the abstraction." Explain that Go's standard library thrives on tiny interfaces (io.Reader, io.Writer, fmt.Stringer) because they're easy to implement, compose, and mock.

Interview Tip

If asked about "accept interfaces, return structs", explain: accepting interfaces makes functions flexible and testable (pass a mock io.Writer in tests). Returning concrete structs gives callers full access to the type's API without artificial constraints.


Key Takeaways

  • Go interfaces are satisfied implicitly -- no implements keyword
  • An interface value is a (value, type) pair; both must be nil for the interface to equal nil
  • The nil interface trap is the most common interface bug -- never return typed nil pointers through interfaces
  • Small interfaces are powerful: io.Reader (1 method) enables the entire I/O ecosystem
  • Accept interfaces, return structs is the guiding design principle
  • Define interfaces at the call site, not alongside the implementation
  • Interface composition via embedding builds complex contracts from simple pieces
  • any (alias for interface{}) accepts all types but sacrifices compile-time safety
  • Common stdlib interfaces to know: error, fmt.Stringer, io.Reader, io.Writer, io.Closer, sort.Interface, http.Handler