Go Interfaces and Composition
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Go has no implements keyword: a type satisfies an interface by having the right methods. Interfaces can therefore be declared after the fact, in the package that consumes them — and that flexibility comes with one sharp trap around nil.
Declaration and implicit satisfaction
type Describer interface {
Describe() string
}
type User struct {
Name string
}
func (u User) Describe() string {
return "user: " + u.Name
}
Nothing was declared, yet User is a Describer:
var d Describer = User{Name: "ann"}
fmt.Println(d.Describe()) // user: ann
To check satisfaction at compile time, use the blank identifier:
var _ Describer = (*User)(nil)
This line does nothing at runtime, but it fails the build the moment the method set drifts. Far better than discovering it at runtime through a failed assertion.
Small interfaces
The standard library’s most useful interfaces are one or two methods wide:
type Stringer interface{ String() string }
type Reader interface{ Read(p []byte) (int, error) }
type Writer interface{ Write(p []byte) (int, error) }
The narrower the interface, the more types can satisfy it, and the more places it can be used. Need several capabilities? Compose them:
type ReadWriter interface {
Reader
Writer
}
Where the interface lives matters too: define it in the consuming package, not the implementing one. The consumer knows which slice of behaviour it needs; the implementation should not have to predict every future caller.
// the consumer declares the minimum it needs
type UserStore interface {
Find(id int) (*User, error)
}
type Service struct {
store UserStore // easy to replace in tests
}
This is also why io.Reader is defined next to its users rather than next to os.File.
any: the empty interface
any is an alias for interface{}, meaning “some type, unknown”:
func printAny(v any) {
fmt.Println(v)
}
var x any = 42
x = "hello" // reassignment across types is allowed
any gives up type checking, so reserve it for genuinely generic plumbing: logging fields, JSON values, containers. If a type parameter or a concrete type would work, use that instead — any pushes the assertion to the caller.
Type assertions
var v any = "hello"
s, ok := v.(string) // safe form: ok is false on mismatch, s is zero
fmt.Println(s, ok) // hello true
n := v.(int) // panics if the dynamic type is not int
Prefer the comma-ok form. Use the single-value form only where the type is guaranteed by construction, such as a value you just stored in a map.
A type switch handles several possibilities at once:
switch x := v.(type) {
case string:
fmt.Println("string of length", len(x))
case int, int64:
fmt.Printf("integer %v\n", x)
case error:
fmt.Println("error:", x.Error())
case nil:
fmt.Println("nil interface")
default:
fmt.Printf("something else: %T\n", x)
}
Inside a multi-type case such as case int, int64, x keeps the original interface type — you cannot use it as an int without asserting again.
The nil interface trap
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }
func do() error {
var e *MyErr = nil
return e // not nil!
}
err := do()
fmt.Println(err == nil) // false
An interface value is a pair: a dynamic type and a dynamic value. As long as the type word is populated (*MyErr), the interface is non-nil even though the value word is a nil pointer. This is why if err != nil fires on a “successful” call that happens to return a typed nil.
The fix is to return a real nil:
func do() error {
return nil
}
or to keep the concrete pointer out of the interface until you know it is non-nil:
func do() error {
var e *MyErr
if e != nil {
return e
}
return nil
}
The bug class appears whenever a function variable holds a concrete pointer type and gets returned as an interface: return p where p == nil but p’s type implements the interface.
Composition instead of inheritance
Go composes with embedded structs (see Go Maps and Structs) and with embedded interfaces, which is how you implement part of a contract and inherit the rest:
type Logger interface {
Info(msg string)
Error(msg string)
}
// only Info is implemented here; Error comes from the embedded interface
type Service struct {
Logger
name string
}
func (s *Service) Info(msg string) {
s.Logger.Info("[" + s.name + "] " + msg)
}
A Service now satisfies Logger as long as the embedded value is non-nil; calling s.Error(...) forwards to it. This is also the standard way to build test doubles that stub a single method.
The outer method shadows the embedded one. There is no override and no dynamic dispatch back into the outer type — which is exactly the difference from class-based inheritance, and the reason “base class calls template method” patterns do not translate.
A full example: dependency injection
package main
import (
"errors"
"fmt"
)
type Store interface {
Get(key string) (string, error)
}
type MemoryStore struct {
data map[string]string
}
func (m *MemoryStore) Get(key string) (string, error) {
v, ok := m.data[key]
if !ok {
return "", fmt.Errorf("key not found: %s", key)
}
return v, nil
}
type Service struct {
store Store
}
func (s *Service) Lookup(key string) string {
v, err := s.store.Get(key)
if err != nil {
return "(missing)"
}
return v
}
func main() {
store := &MemoryStore{data: map[string]string{"name": "ann"}}
svc := &Service{store: store}
fmt.Println(svc.Lookup("name"))
fmt.Println(svc.Lookup("city"))
}
// ann
// (missing)
In tests, swap Store for a stub returning fixed values and Service is testable without touching real storage. That is the whole point of depending on a narrow interface rather than a concrete type.
Common mistakes
1. Returning a typed nil pointer as an interface
err != nil is then true for a success path. Return an untyped nil.
2. Assigning a value to an interface whose methods use pointer receivers
Compile error. Assign &T{}.
3. Designing wide interfaces
Hard to implement, hard to fake in tests. Split into small ones and compose.
4. Reaching for any where generics fit
Type checking disappears and every use site needs an assertion.
5. Asserting without the ok form
A type mismatch panics at runtime instead of taking a branch.
6. Expecting inheritance semantics from embedding
Embedding promotes and shadows; it does not dispatch dynamically.
Summary
- Satisfaction is implicit; verify with
var _ I = (*T)(nil). - Keep interfaces small, define them where they are consumed, compose for wider contracts.
- Assert with
v, ok := x.(T); use type switches for multiple branches. - An interface is nil only when its type word is nil — typed nil pointers are not nil.
- Composition (embedded structs and interfaces) replaces inheritance; methods shadow, never override.
Next: back to the start of the series, or continue into Go packages and modules.