Skip to content
ioob.dev
Go back

Go Part 7 — Interfaces

· 5 min read
Go Series (7/12)
  1. Go Basics Part 1 — Variables, Constants, and Types
  2. Go Basics Part 2 — Conditionals and Loops
  3. Go Basics Part 3 — Functions
  4. Go Basics Part 4 — Error Handling
  5. Go Basics Part 5 — Arrays, Slices, and Maps
  6. Go Basics Part 6 — Structs and Methods
  7. Go Part 7 — Interfaces
  8. Go Part 8 — Pointers
  9. Go Part 9 — Goroutines and Channels
  10. Go Part 10 — Concurrency Patterns
  11. Go Part 11 — Packages and Modules
  12. Go Part 12 — Generics and Practical Patterns
Table of contents

Table of contents

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.

-> Part 8: Pointers


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go Basics Part 6 — Structs and Methods
Next Post
Go Part 8 — Pointers