Table of contents
- The Promise of an Interface
- Why Make It Implicit?
- The Empty Interface — any
- Type Assertions
- Type Switches
- Interface Composition
- io.Reader and io.Writer
- The Interface nil Trap
The Promise of an Interface
If you’ve used interfaces in other languages, you might feel like something’s missing when you first encounter Go’s interfaces. There’s no implements keyword. In Java or C#, you must explicitly declare “this type implements this interface,” but Go interfaces work without any such declaration.
Let’s define an interface.
package main
import "fmt"
type Speaker interface {
Speak() string
}
Speaker is compatible with any type that has a Speak() method. Now let’s create types that “implement” this interface.
type Dog struct {
Name string
}
func (d Dog) Speak() string {
return d.Name + ": Woof!"
}
type Cat struct {
Name string
}
func (c Cat) Speak() string {
return c.Name + ": Meow~"
}
func greet(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
greet(Dog{Name: "Choco"})
greet(Cat{Name: "Nabi"})
}
Neither Dog nor Cat declares anywhere that they implement Speaker. They simply have a Speak() string method, and that alone satisfies the Speaker interface. This is called implicit implementation.
Visualizing the implicit implementation relationship between structs and interfaces makes it more intuitive.
flowchart LR
subgraph Interface
Speaker["Speaker\n─────────\n+ Speak() string"]
end
subgraph Structs
Dog["Dog\n─────────\n+ Name string\n+ Speak() string"]
Cat["Cat\n─────────\n+ Name string\n+ Speak() string"]
end
Dog -. "implicit implementation\n(no implements)" .-> Speaker
Cat -. "implicit implementation\n(no implements)" .-> Speaker
greet["greet(s Speaker)"]
Speaker --> greet
Why Make It Implicit?
There’s a very practical reason behind this design. A type you create can automatically satisfy an interface defined by someone else. Library A defines an interface, and Library B creates a type without knowing about that interface — if the method signatures match, the two connect.
This is impossible in Java. Since you must write implements in the source code, attaching an interface to an external library type you can’t modify requires workarounds like the adapter pattern. Go doesn’t need such workarounds.
It’s also effective at reducing coupling. Since the interface definer and implementer can cooperate without knowing about each other, dependency directions are naturally organized.
The Empty Interface — any
Go has an interface with no methods at all. Since every type satisfies it, it becomes a container that can hold any value.
package main
import "fmt"
func printAnything(v any) {
fmt.Println(v)
}
func main() {
printAnything(42)
printAnything("hello")
printAnything(true)
printAnything([]int{1, 2, 3})
}
any is an alias for interface{} introduced in Go 1.18. You’ll often see interface{} in older code, and the two mean exactly the same thing.
While convenient, don’t overuse it. Values received as any lose their type information, so extracting them always requires type checking. Without the compiler’s help, the possibility of runtime errors opens up.
Type Assertions
A type assertion is how you check and extract the actual type of a value received as any or an interface type.
package main
import "fmt"
func describe(v any) {
// Basic form: panics on failure
s := v.(string)
fmt.Println("string:", s)
}
func describeSafe(v any) {
// Safe form: ok pattern
s, ok := v.(string)
if ok {
fmt.Println("string:", s)
} else {
fmt.Println("not a string")
}
}
func main() {
describeSafe("hello")
describeSafe(42)
// describe(42) // panic: interface conversion error
}
v.(string) means “if v is actually a string, extract that value.” With a single return value, failure causes a panic; with the second ok return value, you can handle it safely.
In practice, the ok pattern is almost always used. Since panics kill the program, using the single-return form when the type is uncertain is risky.
Type Switches
While a type assertion checks a single type, a type switch can branch on multiple types at once.
package main
import "fmt"
func classify(v any) string {
switch val := v.(type) {
case int:
return fmt.Sprintf("integer: %d", val)
case string:
return fmt.Sprintf("string: %q (length %d)", val, len(val))
case bool:
if val {
return "true"
}
return "false"
case nil:
return "nil"
default:
return fmt.Sprintf("unknown type: %T", val)
}
}
func main() {
fmt.Println(classify(42))
fmt.Println(classify("Go"))
fmt.Println(classify(true))
fmt.Println(classify(nil))
fmt.Println(classify(3.14))
}
v.(type) looks similar to a regular type assertion but is special syntax that can only be used inside a switch statement. In each case, val is already converted to that type, so you can use it directly without additional assertions.
This pattern is particularly useful when dealing with JSON parsing results. Type switches frequently appear when extracting values from a map[string]any after json.Unmarshal.
Interface Composition
Go interfaces can embed other interfaces, composing them. This is how you combine small interfaces to create larger contracts.
package main
import "fmt"
type Reader interface {
Read() string
}
type Writer interface {
Write(data string)
}
type ReadWriter interface {
Reader
Writer
}
type File struct {
content string
}
func (f *File) Read() string {
return f.content
}
func (f *File) Write(data string) {
f.content = data
}
func process(rw ReadWriter) {
rw.Write("hello, Go")
fmt.Println(rw.Read())
}
func main() {
f := &File{}
process(f)
}
ReadWriter includes both Reader and Writer. This pattern is used throughout the Go standard library. Types like io.ReadWriter, io.ReadCloser, and io.ReadWriteCloser are all created through such composition.
The Go community has a saying: “Keep interfaces small.” Single-method interfaces have the highest reusability, and you compose them when needed.
io.Reader and io.Writer
If you had to pick the two most important interfaces in the Go standard library, they would be io.Reader and io.Writer.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Just one method each. These simple interfaces run through file I/O, network communication, compression, encryption, and HTTP request/response. A file is a Reader, an HTTP response body is a Reader, and a compression stream is also a Reader.
Building one yourself gives you a feel for it.
package main
import (
"fmt"
"io"
"strings"
)
type UpperReader struct {
src io.Reader
}
func (u *UpperReader) Read(p []byte) (int, error) {
n, err := u.src.Read(p)
for i := 0; i < n; i++ {
if p[i] >= 'a' && p[i] <= 'z' {
p[i] -= 32 // lowercase -> uppercase
}
}
return n, err
}
func main() {
original := strings.NewReader("hello, go interfaces!")
upper := &UpperReader{src: original}
data, _ := io.ReadAll(upper)
fmt.Println(string(data)) // HELLO, GO INTERFACES!
}
UpperReader converts data read from an inner Reader to uppercase. This pattern of wrapping an existing Reader to add new behavior is called the decorator pattern, and in Go, interfaces make this kind of composition very natural.
strings.NewReader, bufio.NewReader, gzip.NewReader — types with Reader in their name all implement io.Reader and can be chained together. This consistency is one of Go’s powerful strengths.
The Interface nil Trap
There’s one caveat to be aware of with interfaces. Interface values internally store a (type, value) pair, which can lead to counterintuitive results.
package main
import "fmt"
type MyError struct {
Message string
}
func (e *MyError) Error() string {
return e.Message
}
func mayFail(fail bool) error {
var err *MyError = nil
if fail {
err = &MyError{Message: "failed!"}
}
return err // Here's the trap
}
func main() {
err := mayFail(false)
if err != nil {
fmt.Println("error occurred:", err) // This line executes!
} else {
fmt.Println("success")
}
// Why? Looking inside the interface:
fmt.Printf("type: %T, value: %v, nil?: %v\n", err, err, err == nil)
// type: *main.MyError, value: <nil>, nil?: false
}
mayFail(false) returns a nil pointer of type *MyError. But when this value is converted to the error interface, the interface internally holds (type: *MyError, value: nil). Since there’s type information, the interface itself is judged as non-nil.
The correct fix is to return nil directly as the interface type.
func mayFailFixed(fail bool) error {
if fail {
return &MyError{Message: "failed!"}
}
return nil // A true nil for the error interface
}
This is a trap you’ll inevitably encounter when working with Go, so knowing about it ahead of time can save you significant debugging time.
Interfaces are the core mechanism supporting polymorphism in Go. Thanks to implicit implementation, coupling between code stays loose, and complex behavior can be expressed through composition of small interfaces. The io.Reader and io.Writer patterns in particular are deeply embedded throughout the Go ecosystem, so getting comfortable with them makes working with the standard library much easier.
The next part covers pointers. We’ll look at the meaning of & and *, the difference between pass-by-value and pass-by-pointer, and nil pointers.




Loading comments...