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:
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:
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¶
- Keep interfaces small -- one or two methods is ideal. The
io.Reader(1 method) is far more powerful than any 10-method interface - Define interfaces where they're used, not where they're implemented -- the consumer defines what it needs
- Accept interfaces, return structs -- maximizes flexibility for callers and testability
- Use compile-time interface checks --
var _ Interface = (*Type)(nil)catches drift early - Prefer
anyoverinterface{}in Go 1.18+ code for readability - Don't create interfaces preemptively -- wait until you have at least two concrete types or a testing need
- 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:
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
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
implementskeyword - 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 forinterface{}) 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