Go Intermediate #1 Interfaces — the Meaning of Implicit Implementation
The first post in the Go Intermediate series. After finishing the 7-part Basics series you can confidently build small tools — Intermediate is where you add Go’s real strengths to your toolkit.
It’s structured as 7 posts.
- #1 Interfaces ← this post
- #2 Error-handling patterns
- #3 Goroutines and channels intro
- #4 select and timeouts
- #5 context.Context in depth
- #6 Testing
- #7 Standard library tour
This post covers one of Go’s most distinctive design decisions: interfaces.
Defining an interface #
type Speaker interface {
Speak() string
}type Name interface { methods } — the shape itself looks like other languages. The difference is in how you implement them.
Implicit implementation — Go’s signature #
Go interfaces have no implements keyword.
type Speaker interface {
Speak() string
}
type Dog struct{}
func (d Dog) Speak() string {
return "woof"
}
func main() {
var s Speaker = Dog{} // automatically implemented
fmt.Println(s.Speak())
}If Dog has the Speak() string method — it automatically satisfies the Speaker interface. There’s no “Dog implements Speaker” declaration anywhere.
This is called structural typing or the compile-time version of duck typing. TypeScript’s interfaces work the same way. The opposite of explicit interface systems like Java’s.
Why this design? #
The big benefits of this design:
- Interfaces can be defined at the use site — even if a library doesn’t expose interfaces, the consumer can define an interface for the method set they need.
- Loose coupling — type from library A and type from library B can satisfy the same interface (without knowing about each other).
- Gradual abstraction — you can start with concrete types and extract interfaces later.
The heart of interfaces — defined at the use site #
Where traditional OOP says “the library defines the interface and implementers implement it,” Go makes it natural for the consumer to define the interface they need.
package mylogger
// what our logger needs — a Writer interface
type Writer interface {
Write(p []byte) (n int, err error)
}
func WriteLog(w Writer, msg string) {
w.Write([]byte(msg))
}import (
"os"
"bytes"
"mylogger"
)
func main() {
mylogger.WriteLog(os.Stdout, "to stdout") // os.File satisfies Writer
mylogger.WriteLog(&bytes.Buffer{}, "to a buffer") // bytes.Buffer satisfies Writer too
}Neither os.File nor bytes.Buffer knew anything about mylogger.Writer. We defined the shape we needed as an interface in our own code, and both types happen to satisfy it, so they’re automatically compatible.
The standard library’s core interfaces — io.Reader, io.Writer
#
The two most-used interfaces. Go’s whole I/O system stands on these.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}A very small interface with just one method. So:
os.Filesatisfies both (file read/write)net.Connsatisfies both (network send/receive)bytes.Buffersatisfies both (in-memory buffer)strings.Readersatisfies Readergzip.Writersatisfies Writer (compress + forward to another Writer)
The same function can handle a file, a network socket, or an in-memory buffer the same way. An incredibly powerful abstraction.
func processData(r io.Reader) error {
// ...
}
processData(file) // file
processData(httpResponse.Body) // network
processData(strings.NewReader("hello")) // string
processData(&bytes.Buffer{}) // memoryThe small-interface guide #
A strong convention in the Go community:
Smaller interfaces are better. One or two methods is ideal.
The reason for this guide: small interfaces are automatically satisfied by more types. As the method count grows, fewer types match and the interface becomes less useful.
io.Reader, io.Writer, error, fmt.Stringer — all have one method. When you need a bigger interface, build it as a composition of smaller interfaces.
type ReadWriter interface {
Reader
Writer
}That’s exactly the definition of io.ReadWriter.
The empty interface — interface{} (= any)
#
An interface with zero methods is satisfied by every type.
type Empty interface{} // old notation
// any is the standard alias from Go 1.18+
var x any = 42
var y any = "hello"
var z any = []int{1, 2, 3}any is an alias for interface{} (Go 1.18+). Almost all new code uses any.
Similar to JavaScript’s any or TypeScript’s unknown. Accepts every type — but must be narrowed before use.
Type assertion #
To pull out the type that an any actually holds — type assertion.
var v any = "hello"
s := v.(string)
fmt.Println(s) // hello
n := v.(int) // ✗ panic: interface conversion: ...OK if the type matches, panic if not. Risky.
Safe assertion — comma-ok #
var v any = "hello"
if s, ok := v.(string); ok {
fmt.Println("string:", s)
} else {
fmt.Println("not a string")
}If the second value ok is false, the type didn’t match. No panic. This form is safe in nearly every case.
Type switch — branching on multiple types #
When any could be one of several types.
func describe(i any) {
switch v := i.(type) {
case int:
fmt.Printf("int: %d\n", v)
case string:
fmt.Printf("string: %s\n", v)
case bool:
fmt.Printf("bool: %t\n", v)
case []int:
fmt.Printf("[]int: %v\n", v)
default:
fmt.Printf("unknown type %T\n", v)
}
}Special syntax switch v := i.(type). Inside each case, v is narrowed to that type. Similar to a JS typeof switch.
The nil pitfall of interface variables #
The most confusing part.
type MyError struct{ msg string }
func (e *MyError) Error() string {
return e.msg
}
func doSomething() error {
var err *MyError = nil
return err // a nil *MyError gets boxed into an error interface
}
func main() {
err := doSomething()
if err != nil {
fmt.Println("has error") // ✗ this prints!
}
}err != nil becomes true. Why — an interface value is a (type, value) pair, so even when the inner value is nil, the interface itself isn’t nil unless the type is nil too.
Fix: return nil explicitly from the function, or have the return type be the interface directly.
func doSomething() error {
var err *MyError = nil
if err == nil {
return nil // return interface nil directly
}
return err
}This trap is very surprising on first encounter, and you’ll meet it in practice. Comparing with standard tools like errors.Is is safer in some places (next post).
Compile-time check that a type satisfies an interface #
When the code wants to make explicit that a particular type must satisfy a particular interface.
var _ Speaker = (*Dog)(nil)This one line says — “compile error if Dog doesn’t satisfy Speaker.” var _ says I won’t use the variable, and (*Dog)(nil) is a nil *Dog value. If Dog forgets the Speak() method, compilation is blocked.
The standard pattern when a library wants to confirm its type implements an external interface correctly.
Interface vs concrete type — where to abstract #
Guidelines:
- Function parameters → typically interfaces (as small as possible)
- Function return values → typically concrete types
Shortened as “Accept interfaces, return concrete types.”
// Good
func ReadConfig(r io.Reader) (*Config, error) {
// ...
}
// Odd pattern — returning an interface is uncommon
func ReadConfig(r io.Reader) (interface{}, error) {
// ...
}Accepting an interface as a parameter means callers can pass more varied types and tests are easier to write with mocks. Returning a concrete type lets callers use every method on it.
Common standard interfaces #
// string representation
type Stringer interface {
String() string
}
// errors
type error interface {
Error() string
}
// comparable
type Comparable[T any] interface {
Compare(T) int
}
// sorting
type Sort interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}When fmt.Println prints an object — if that object implements Stringer, it automatically uses the result of String(). The standard place to define your type’s display form.
Wrap-up #
What we covered:
- Interfaces have implicit implementation — no
implementskeyword - Interfaces can be defined at the use site — loose coupling
- Small interfaces like
io.Reader,io.Writerare powerful - Interfaces are better when small — 1–2 methods
- Empty interface =
any(Go 1.18+) - Comma-ok type assertion and type switch
- The nil pitfall of interface variables (type present, value nil)
- “Accept interfaces, return concrete types”
- Standard interfaces like
Stringer,error
In the next post (#2 Error-Handling Patterns) we go deeper on the errors you saw in Basics #4 — wrapping, errors.Is/As, and custom-error-type patterns.