Single-Method Interfaces: A More General Abstraction Than Higher-Order Functions
Single-method interfaces (SMIs) are one of the most pervasive abstraction tools in Go. You find them throughout the standard library and in virtually any substantial Go codebase. Their success isn't accidental—SMIs occupy a sweet spot that combines the flexibility of functional programming with the extensibility of Go's type system.
To understand why SMIs are so effective, it helps to compare them with higher-order functions (HOFs). A direct comparison reveals three key facts:
- SMIs can do everything HOFs can do
- SMIs are more general than HOFs
- SMIs are slightly more verbose for simple cases
Rewriting a HOF Example with SMIs
Consider a tree search algorithm originally designed around HOFs. The earlier version used function types like GoalP, Successors, and Combiner to parameterize the search. The same logic can be expressed with SMIs instead:
type State int
type States []State
// GoalDetector is an interface that wraps a single IsGoal method. IsGoal
// takes a state and determines whether it's a goal state.
type GoalDetector interface {
IsGoal(s State) bool
}
// SuccessorGenerator is an interface that wraps a single Successors method.
// Successors returns the successors of a state.
type SuccessorGenerator interface {
Successors(s State) States
}
// Combiner is an interface that wraps a single Combine method. Combine
// determines the search strategy by combining successors of the current state
// with all the other states into a single list of states.
type Combiner interface {
Combine(succ States, others States) States
}
The search function itself remains nearly identical to the HOF version, operating on these interfaces rather than function types:
func treeSearch(states States, gd GoalDetector, sg SuccessorGenerator, combiner Combiner) State {
if len(states) == 0 {
return -1
}
first := states[0]
if gd.IsGoal(first) {
return first
} else {
return treeSearch(combiner.Combine(sg.Successors(first), states[1:]), gd, sg, combiner)
}
}
Implementing specific search strategies like BFS and DFS requires the same helper functions as before—prependOthers and appendOthers—but there's a catch. Since treeSearch now accepts interfaces, passing a plain function like prependOthers requires an adapter:
func bfsTreeSearch(start State, gd GoalDetector, sg SuccessorGenerator) State {
return treeSearch(States{start}, gd, sg, CombineFunc(prependOthers))
}
This adapter pattern is familiar to Go programmers; it's the same approach used by http.HandlerFunc. Go doesn't yet allow assigning a function directly to a compatible SMI, so these adapters are necessary boilerplate.
type CombineFunc func(States, States) States
func (f CombineFunc) Combine(succ States, others States) States {
return f(succ, others)
}
Beyond the adapter noise, the SMI version is functionally equivalent to the HOF version. SMIs can be passed to functions, returned from functions, and composed just like function types. The standard library is full of such examples.
So is that all there is to it? Not at all. The tree search example was designed for HOFs, so it doesn't reveal why SMIs are genuinely more powerful.
Why SMIs Go Beyond Function Types
A common argument for SMI superiority is that they can carry state, but this is a red herring—closures give function types the same capability. The stateIs and costDiffTarget examples in the tree search code demonstrate this clearly.
The real advantages of SMIs come from two properties unique to Go's type system:
- Interfaces naturally extend to multiple methods
- A single Go value can implement multiple interfaces
Consider the transition from a SMI to a multi-method interface. Interfaces like io.Reader have exactly one method:
type Reader interface {
Read(p []byte) (n int, err error)
}
But Go also has multi-method interfaces, such as io.ReadWriter:
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
The standard library is rich with multi-method interfaces: net/http.ResponseWriter, context.Context, io/fs.File. Extending a SMI to a multi-method interface is trivial in Go—but it's an awkward, unnatural fit for function types.
Even more powerful is Go's implicit interface implementation. A type doesn't declare that it implements an interface—it just needs the right methods. A single value can therefore satisfy multiple unrelated interfaces simultaneously. For instance, encoding.BinaryMarshaler:
type BinaryMarshaler interface {
MarshalBinary() (data []byte, err error)
}
And encoding/json.Marshaler:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
A type like time.Time implements both, enabling it to serialize into binary and JSON formats. Many types also implement fmt.Stringer:
type Stringer interface {
String() string
}
alongside several other interfaces. It's not uncommon for sophisticated types to implement five or more interfaces. This kind of polymorphic flexibility is essentially unachievable with HOFs and function types.
Practical Guidance
SMIs are more general and more powerful than HOFs, but that comes at a cost of verbosity in simple scenarios. When a plain function captures the abstraction cleanly, by all means use a function. Interfaces shine when the abstraction needs multiple methods or when the same value must serve several distinct roles. As in most engineering decisions, the best choice depends on the specific problem—go with the simplest tool that handles the requirements gracefully.
Appendix: Streamlining the Adapter Boilerplate
The Go community has discussed proposals to reduce the friction of converting between plain functions and SMIs. One proposal in an advanced stage (currently awaiting a prototype) would allow writing:
Combiner(prependOthers).Combine
instead of defining a dedicated adapter type. While not as terse as passing the function directly, this would noticeably decrease boilerplate. A separate, older proposal aims for full function-to-interface assignability, which would be a more fundamental change to the language.



