🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

Interfaces

Master Go's core abstraction mechanism. Learn how interfaces enable polymorphism, testing, and flexible code design without inheritance. Understand implicit implementation and interface composition.

You've built types that hold data and methods that operate on those types. Each type is self-containeda Rectangle knows how to calculate its area, a File knows how to read and write. But software doesn't work in isolation. Functions need to work with multiple types without knowing their concrete implementation. A logging function should write to files, network connections, or memory buffers without caring which. A serializer should encode structs, maps, or custom types without knowing the details.

This is the problem interfaces solve. They define contracts based on behavior, not implementation. Instead of saying "this function needs a File," you say "this function needs anything that can read and write." Any type that implements those methods satisfies the interface.

The Interface Contract

An interface specifies method signatures without implementations:

type Writer interface {
    Write([]byte) (int, error)
}

This interface declares one requirement: any type with a Write([]byte) (int, error) method is a Writer. No explicit declaration. No keyword like "implements." If a type has the right methods, it implements the interface automatically.

type File struct {
    path string
}

func (f *File) Write(data []byte) (int, error) {
    // Write to file system
    return len(data), nil
}

type Buffer struct {
    data []byte
}

func (b *Buffer) Write(data []byte) (int, error) {
    b.data = append(b.data, data...)
    return len(data), nil
}

Both File and Buffer implement Writer because both have the required Write method. The implementations are completely differentone writes to disk, one to memorybut both satisfy the contract.

Functions can accept the interface and work with any implementation:

func SaveLog(w Writer, message string) error {
    _, err := w.Write([]byte(message))
    return err
}

file := &File{path: "/var/log/app.log"}
SaveLog(file, "startup complete")

buffer := &Buffer{}
SaveLog(buffer, "test message")

The SaveLog function doesn't know or care whether it's writing to a file or buffer. It only cares that the type can write bytes.

Implicit Implementation

Go interfaces work differently than interfaces in Java or C#. There's no explicit declaration. Types don't announce that they implement an interface. The compiler checks automatically.

type Reader interface {
    Read([]byte) (int, error)
}

type NetworkConnection struct {
    socket int
}

func (nc *NetworkConnection) Read(buffer []byte) (int, error) {
    // Read from network
    return 0, nil
}

// NetworkConnection implements Reader automatically
var r Reader = &NetworkConnection{socket: 42}

This implicit implementation means you can define interfaces after types exist. The standard library's io.Writer interface works with any type that has a Write method, even types defined before io.Writer was conceived.

Adding an interface later doesn't require modifying existing code:

// Your code from months ago
type Logger struct {
    prefix string
}

func (l *Logger) Write(data []byte) (int, error) {
    fmt.Printf("%s: %s", l.prefix, data)
    return len(data), nil
}

// New code can use it as io.Writer
var w io.Writer = &Logger{prefix: "INFO"}
fmt.Fprintf(w, "system started")

The Logger wasn't designed to be an io.Writer, but because it has the right method, it works. This backwards compatibility is powerfulinterfaces can be defined to match existing types rather than forcing types to declare their interfaces upfront.

Interface as Abstraction

Interfaces enable programming against contracts rather than concrete types. Hardware interfaces demonstrate this clearly. USB defines electrical signals and protocols. A computer with a USB port accepts any device implementing that interfacekeyboards, mice, storage drives, microphones. The computer doesn't need custom code for each device. The interface provides the abstraction.

Software interfaces work the same way. HTTP servers need to write responses, but they don't care whether the destination is a network socket, file, or test buffer:

func HandleRequest(w io.Writer, req *Request) {
    response := processRequest(req)
    w.Write([]byte(response))
}

This function works with real HTTP connections and test buffers identically. Tests can verify behavior without opening network sockets:

func TestHandleRequest(t *testing.T) {
    buffer := &bytes.Buffer{}
    req := &Request{Path: "/health"}
    HandleRequest(buffer, req)

    if buffer.String() != "OK" {
        t.Errorf("expected OK, got %s", buffer.String())
    }
}

The production code and test code use the same function because both provide a Writer.

Multiple Interfaces

Types can implement multiple interfaces:

type ReadWriter interface {
    Read([]byte) (int, error)
    Write([]byte) (int, error)
}

type Connection struct {
    socket int
}

func (c *Connection) Read(buffer []byte) (int, error) {
    // Read from socket
    return 0, nil
}

func (c *Connection) Write(data []byte) (int, error) {
    // Write to socket
    return len(data), nil
}

// Connection implements Reader, Writer, and ReadWriter
var r io.Reader = &Connection{socket: 42}
var w io.Writer = &Connection{socket: 42}
var rw io.ReadWriter = &Connection{socket: 42}

A single type satisfies multiple interfaces if it has the required methods. This composition means small interfaces compose into larger ones.

Interface Composition

Interfaces themselves compose:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type ReadWriter interface {
    Reader
    Writer
}

The ReadWriter interface combines Reader and Writer without repeating method signatures. Any type implementing both Read and Write automatically implements ReadWriter.

The standard library uses this pattern extensively:

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Building interfaces through composition keeps individual interfaces small and focused. Small interfaces are easier to implement and test.

The Empty Interface

The empty interface has zero methods:

interface{}

Every type implements the empty interface because every type has at least zero methods. Functions accepting interface{} accept any value:

func PrintValue(v interface{}) {
    fmt.Println(v)
}

PrintValue(42)
PrintValue("hello")
PrintValue(true)

This looks like dynamic typing, but it's not. The value still has a concrete type. The interface just holds any type. To use the value, you need type assertions or type switches (covered below).

The empty interface sacrifices type safety for flexibility. Use it sparinglytyped interfaces are almost always better. Generic code starting in Go 1.18 provides type-safe alternatives in most cases.

Go 1.18 introduced any as an alias for interface{}:

func PrintValue(v any) {
    fmt.Println(v)
}

The behavior is identicalany is clearer syntax for the empty interface.

Type Assertions

Interface values hide their concrete type. Type assertions extract the underlying value:

var w io.Writer = &bytes.Buffer{}
buffer := w.(*bytes.Buffer)  // Type assertion
buffer.Reset()

The assertion w.(*bytes.Buffer) says "w holds a *bytes.Buffer." If correct, buffer contains the concrete value. If wrong, the program panics.

Safe type assertions return two values:

buffer, ok := w.(*bytes.Buffer)
if ok {
    buffer.Reset()
} else {
    // w doesn't hold *bytes.Buffer
}

The second return value indicates success. This form never panics.

Type assertions are necessary when you need capabilities beyond the interface. The interface provides the guaranteed contract, but sometimes you need specific features:

func CloseIfPossible(c io.Writer) {
    if closer, ok := c.(io.Closer); ok {
        closer.Close()
    }
}

This function accepts any Writer, but if that Writer is also a Closer, it closes the connection. Types that don't implement Close are ignored safely.

Type Switches

When checking multiple types, use a type switch:

func HandleValue(v interface{}) {
    switch x := v.(type) {
    case int:
        fmt.Printf("integer: %d\n", x)
    case string:
        fmt.Printf("string: %s\n", x)
    case bool:
        fmt.Printf("boolean: %t\n", x)
    default:
        fmt.Printf("unknown type: %T\n", x)
    }
}

The x := v.(type) syntax extracts the concrete type. Each case checks a different type, and x has the appropriate type within that case. Type switches handle multiple types cleanly without cascading type assertions.

Interface Values

An interface value holds two components: a concrete type and a concrete value. When you assign a value to an interface, both pieces are stored:

var w io.Writer
w = &bytes.Buffer{}  // type=*bytes.Buffer, value=&bytes.Buffer{...}

The interface w now contains the type *bytes.Buffer and the actual buffer pointer. Calling w.Write() dispatches to the concrete type's method.

A nil interface has neither type nor value:

var w io.Writer  // w is nil
w.Write([]byte("hello"))  // panic: nil pointer

An interface holding a nil pointer is not a nil interface:

var buffer *bytes.Buffer  // buffer is nil
var w io.Writer = buffer  // w is not nil
if w != nil {             // true - w holds type *bytes.Buffer
    w.Write([]byte("hello"))  // panic: nil pointer dereference
}

This distinction trips up many developers. The interface w is not nil because it has a type (*bytes.Buffer), even though the value is nil. Calling methods on a nil value panics.

Check for nil before assignment or after type assertion:

var buffer *bytes.Buffer
if buffer != nil {
    var w io.Writer = buffer
    w.Write([]byte("hello"))
}

Interface Design Principles

Small interfaces are better than large ones. The standard library favors interfaces with one or two methods:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type Closer interface {
    Close() error
}

Small interfaces are easier to implement, test, and understand. They compose into larger interfaces when needed.

Define interfaces in the package that uses them, not the package that implements them. If package http needs a Writer, define Writer in package http (or use io.Writer). Don't export interfaces from packages that only implement them. This keeps dependencies flowing in one direction and makes interfaces easier to satisfy.

Accept interfaces, return concrete types. Functions should take interfaces as parameters for flexibility:

func SaveData(w io.Writer, data []byte) error {
    _, err := w.Write(data)
    return err
}

Return concrete types so callers get the full type's capabilities:

func NewLogger(prefix string) *Logger {
    return &Logger{prefix: prefix}
}

Returning io.Writer would work, but returning *Logger gives callers access to all Logger methods, not just Write.

Standard Library Interfaces

The standard library defines fundamental interfaces used throughout Go code.

io.Reader reads bytes:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Files, network connections, HTTP request bodies, and compressed streams all implement Reader. Functions accepting Reader work with all of them.

io.Writer writes bytes:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Files, network connections, HTTP response bodies, and buffers implement Writer. Any function writing output should accept a Writer.

io.Closer releases resources:

type Closer interface {
    Close() error
}

Files and network connections implement Closer. Always close resources when finished:

func ProcessFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // Process file
    return nil
}

fmt.Stringer provides string representations:

type Stringer interface {
    String() string
}

Types implementing String() integrate with fmt.Print family functions:

type User struct {
    Name string
    ID   int
}

func (u User) String() string {
    return fmt.Sprintf("User(%s, %d)", u.Name, u.ID)
}

user := User{Name: "Alice", ID: 42}
fmt.Println(user)  // User(Alice, 42)

error is an interface:

type error interface {
    Error() string
}

Any type with an Error() string method is an error. This makes custom error types simple.

Real-World Example: HTTP Handlers

Go's HTTP handlers demonstrate interface-driven design. The standard library defines:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Any type implementing ServeHTTP handles HTTP requests. The HTTP server doesn't care about concrete types:

type HelloHandler struct{}

func (h HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, World!"))
}

http.Handle("/", HelloHandler{})
http.ListenAndServe(":8080", nil)

The server works with any Handler implementationrouters, middleware, static file servers. They all satisfy the same interface.

Function types can implement interfaces too:

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Now any function with the right signature is a Handler:

http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello"))
}))

This pattern converts functions into interface implementations without defining new types.

Interfaces Enable Testing

Interfaces separate what code does from how it does it. Production code uses databases, network APIs, and file systems. Tests shouldn't depend on these external systems.

Define interfaces for external dependencies:

type UserStore interface {
    Get(id int) (*User, error)
    Create(user *User) error
}

type UserService struct {
    store UserStore
}

func (s *UserService) RegisterUser(name string) error {
    user := &User{Name: name}
    return s.store.Create(user)
}

Production code uses a real database:

type PostgresStore struct {
    db *sql.DB
}

func (p *PostgresStore) Get(id int) (*User, error) {
    // Query database
}

func (p *PostgresStore) Create(user *User) error {
    // Insert into database
}

service := &UserService{store: &PostgresStore{db: db}}

Tests use an in-memory fake:

type FakeStore struct {
    users map[int]*User
}

func (f *FakeStore) Get(id int) (*User, error) {
    return f.users[id], nil
}

func (f *FakeStore) Create(user *User) error {
    f.users[user.ID] = user
    return nil
}

func TestRegisterUser(t *testing.T) {
    store := &FakeStore{users: make(map[int]*User)}
    service := &UserService{store: store}

    err := service.RegisterUser("Alice")
    if err != nil {
        t.Fatal(err)
    }

    if len(store.users) != 1 {
        t.Error("user not created")
    }
}

The UserService doesn't know or care whether it's talking to Postgres or a fake. The interface provides the contract. Tests run instantly without databases.

When Not to Use Interfaces

Interfaces add indirection. Use them when you need abstraction, not by default. Concrete types are simpler and easier to understand.

Don't define interfaces before you need them. Write concrete code first. If multiple implementations emerge or testing becomes difficult, introduce an interface. Premature interfaces add complexity without benefit.

Don't export interfaces with only one implementation. If your package defines an interface that only your package implements, the interface might be unnecessary. Exceptions existinterfaces for testing or to document contractsbut most single-implementation interfaces are premature.

Key Takeaways

Interfaces define behavior through method signatures. Types implement interfaces automatically by having the required methods. No explicit declaration.

Implicit implementation enables backwards compatibility. New interfaces work with existing types. Code doesn't need modification when new interfaces are defined.

Small interfaces compose into larger ones. Single-method interfaces are common and powerful. Composition builds complex interfaces from simple ones.

Interfaces separate what from how. Code depending on interfaces works with any implementation. This enables testing, flexibility, and decoupling.

Accept interfaces, return concrete types. Functions take interfaces for flexibility. Return concrete types to give callers full capabilities.

The empty interface accepts any type but sacrifices type safety. Use sparingly.

Interface values hold type and value. A nil pointer in an interface is not a nil interface. Check the concrete value, not the interface.

Standard library interfaces provide common contracts. io.Reader, io.Writer, io.Closer, fmt.Stringer, and error appear throughout Go code. Implementing these interfaces integrates with existing functionality.

Interfaces are Go's core abstraction mechanism. They enable polymorphism without inheritance, testing without mocks, and flexibility without complexity. Understanding interfaces means understanding how Go programs compose.


Ready to learn how Go handles errors as values?

Notes & Highlights

© 2025 projectlighthouse. All rights reserved.