12.5 Interface Composition: Embedding Interfaces in Interfaces
Right, so you’ve got the hang of defining a single interface. Neat. But the real world, as usual, is messier. You’ll often find that what you actually need is a combination of behaviors. You could just define one giant SuperDuperWriterCloserLogger interface, but that’s brittle, inflexible, and frankly, it reeks of a committee-designed Java library from 2003. We’re better than that.
Go’s answer is interface composition. It’s the idea that you can build complex interfaces by embedding smaller, focused ones inside them. It’s like building with Lego bricks instead of carving a monolith out of a single piece of rock. This is one of the most elegant features of the language, and it’s why you’ll see interfaces like io.ReadWriter all over the standard library. Let’s break it down.
The Mechanics: It’s Just Embedding, Again
If you recall how embedding works in structs, this is exactly the same concept, just applied to interfaces. The syntax is identical. You simply list the interfaces you want to combine inside the new interface’s definition.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// ReadCloser is a composite interface.
// Any type that implements ReadCloser must implement both Read and Close.
type ReadCloser interface {
Reader
Closer
}
This isn’t magic. The compiler effectively “unpacks” the ReadCloser interface. When it checks if a type satisfies ReadCloser, it’s checking for the method sets of Reader and Closer. The union of those method sets is the ReadCloser interface. It’s beautifully simple.
Why Bother? The Power of Small, Focused Contracts
The genius of this approach is that it promotes incredibly focused, single-method interfaces. You can define a Reader, a Writer, a Closer—tiny, atomic units of behavior. Then, you can compose them to create the precise contract your function needs.
Imagine a function that needs to read data and then close the resource. Without composition, you’d have to define your own MySpecificReadAndCloseThing interface. Yuck. With composition, you just use the existing, standard io.ReadCloser.
func processAndClose(rc io.ReadCloser) error {
defer rc.Close() // We know we can Close because it's in the contract.
data, err := io.ReadAll(rc)
if err != nil {
return err
}
// ... process data ...
return nil
}
This function is now incredibly flexible. It doesn’t care if rc is a file, a network connection, or a custom TicTacToeReaderCloser you wrote in your garage. It only cares that it can Read from it and Close it. This is the essence of loose coupling.
The Name Collision Nightmare (And How Go Avoids It)
Here’s the obvious question: what happens if two embedded interfaces have a method with the same name? Let’s say we have two interfaces with a Do() method.
type Alpha interface {
Do() string
}
type Beta interface {
Do() int
}
type Gamma interface {
Alpha
Beta
}
What is the method set of Gamma? Is it Do() string and Do() int? That would be a disaster—the compiler would have no idea which Do to call. Thankfully, Go is smarter than that. This is an invalid interface. The Go spec is clear: if two embedded interfaces have methods with the same name and identical signatures, it’s fine—it’s just one method. But if the signatures differ, the composite interface itself is invalid and won’t compile. The language designers made a brilliant choice here: they rejected ambiguity at the definition site. You find out your interface is broken the moment you write it, not hours later when you try to assign something to it.
The Empty Interface Landmine
A common pitfall is thinking embedding any (or interface{}) does something useful. Let’s be direct: it doesn’t. The empty interface has no methods. Composing with it is like adding zero.
type MyInterface interface {
io.Writer
any // This is completely redundant.
}
The method set of MyInterface is just the method set of io.Writer. You’ve added nothing but visual noise to your code. Don’t do this. It’s a tell-tale sign someone is still thinking in terms of inheritance rather than composition.
Best Practice: Compose, Don’t Redefine
The standard library is your best guide here. Look at io.ReadWriter:
// In the io package
type ReadWriter interface {
Reader
Writer
}
Notice what they didn’t do. They didn’t redefine Read and Write methods. They embedded the existing interfaces. This is crucial. If the definition of io.Reader ever changes (it won’t, but hypothetically), io.ReadWriter automatically inherits that change. You’re creating a contract that is explicitly and semantically built from other contracts. Your code becomes a declaration of intent: “I need something that can Read and Write.” It’s clean, it’s maintainable, and it’s how you’re meant to use the language. Stop writing big interfaces. Start composing small ones.