Go in 2 Weeks #04: Interfaces, Duck Typing, any and Type Assertions

Key takeaways

How Go replaces C++ virtual functions and inheritance with implicitly satisfied interfaces: method sets, pointer vs value receivers, io.Reader/io.Writer, any, type assertions and type switches, and why Go interfaces stay small.

Series overview

Go in 2 Weeks #04

This post covers Day 7 of the two-week Go curriculum for C++ developers.

Previous: #03 OOP & composition ← | → Next: #05 Error handling


Introduction: from explicit inheritance to implicit satisfaction

In C++, polymorphism usually means inheriting from a base class and marking overrides virtual. Go has no inheritance declaration at all: if a type has the methods an interface lists, it satisfies that interface. This is often described as duck typing (“if it walks like a duck and quacks like a duck, it is a duck”), except that Go checks it at compile time wherever a concrete value is assigned to an interface type.

The practical consequence is a change in who owns the abstraction. In C++ the library author designs the base class and every implementer must inherit from it. In Go the consumer declares the small interface it needs, and existing types from any package fit it without being edited.

You will learn:

  • Interface definition, method sets, and implicit satisfaction
  • How pointer vs value receivers decide whether a type satisfies an interface
  • Key standard-library interfaces (io.Reader, io.Writer, fmt.Stringer, error)
  • The empty interface (any), type assertions, and type switches
  • Interface design guidelines, and the traps that bite C++ developers first

Interface basics: method sets

C++ vs Go: polymorphism

// C++: polymorphism with virtual functions
class Shape {
public:
    virtual double Area() const = 0;
    virtual double Perimeter() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius;
public:
    Circle(double r) : radius(r) {}
    double Area() const override { return 3.14159 * radius * radius; }
    double Perimeter() const override { return 2 * 3.14159 * radius; }
};

class Rectangle : public Shape {
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    double Area() const override { return width * height; }
    double Perimeter() const override { return 2 * (width + height); }
};

void printShapeInfo(const Shape& s) {
    std::cout << "Area: " << s.Area() << "\n";
    std::cout << "Perimeter: " << s.Perimeter() << "\n";
}
// Go: polymorphism with interfaces (no explicit inheritance)
package main

import (
    "fmt"
    "math"
)

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Circle struct{ Radius float64 }

// Circle has Area() and Perimeter(), so it satisfies Shape automatically.
func (c Circle) Area() float64      { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }

type Rectangle struct{ Width, Height float64 }

func (r Rectangle) Area() float64      { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }

func printShapeInfo(s Shape) {
    fmt.Printf("Area: %.2f\n", s.Area())
    fmt.Printf("Perimeter: %.2f\n", s.Perimeter())
}

func main() {
    printShapeInfo(Circle{Radius: 5})
    printShapeInfo(Rectangle{Width: 4, Height: 6})
}

Key points:

  • No implements keyword, no base class
  • Implement the methods and the type satisfies Shape automatically
  • Circle and Rectangle do not import or even know about Shape, so coupling is lower

What an interface value actually is

An interface value is a two-word pair: a pointer to type information (including the method table for that concrete type) and a pointer to (or copy of) the data. That is conceptually close to a C++ object pointer plus its vtable pointer, but the vtable lives next to the value rather than inside the object. Two consequences follow:

  • Calling a method through an interface is a dynamic dispatch, just like a virtual call, and the compiler usually cannot inline it. In hot loops over concrete types, a plain function taking the concrete type (or a generic function) can be faster.
  • Assigning a non-pointer value to an interface may copy it and, if it escapes, allocate it on the heap. Passing a pointer avoids the copy for large structs.

Method sets: pointer vs value receivers

This is the rule that decides whether a type satisfies an interface, and it trips up almost everyone once:

Receiver of the methodT has it in its method set*T has it in its method set
func (t T) M()yesyes
func (t *T) M()noyes
type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ } // pointer receiver

type Incrementer interface{ Inc() }

func main() {
    var c Counter
    // var i Incrementer = c   // compile error: Counter does not implement Incrementer
    var i Incrementer = &c     // OK: *Counter has Inc
    i.Inc()
}

The first time I hit this, the error message (“method Inc has pointer receiver”) looked like the compiler was being pedantic. It is actually protecting you: if a value c were stored in the interface, Inc would mutate a copy and the caller would never see the change. The fix is almost always to pass &c, and to keep receivers consistent per type, as discussed in #03.


Implicit interface satisfaction

package main

import "fmt"

// Existing types: they know nothing about any interface.
type Dog struct{ Name string }

func (d Dog) Speak() string { return "Woof!" }

type Cat struct{ Name string }

func (c Cat) Speak() string { return "Meow!" }

// Interface defined later, without touching Dog or Cat.
type Speaker interface{ Speak() string }

func makeSpeak(s Speaker) { fmt.Println(s.Speak()) }

func main() {
    makeSpeak(Dog{Name: "Buddy"})
    makeSpeak(Cat{Name: "Whiskers"})
}

Compared to C++: base classes must be designed up front, and a third-party class that you cannot edit can only be adapted with a wrapper. In Go you can introduce Speaker after the fact, in your own package, and third-party types that happen to have Speak() string fit it directly.

Checking satisfaction at compile time

Because nothing declares “UpperWriter implements io.Writer”, a typo in a method signature only shows up where the value is used as that interface, which might be far away or only in a test. The idiomatic guard is a blank assignment next to the type:

var _ io.Writer = (*UpperWriter)(nil) // fails to compile if *UpperWriter stops satisfying io.Writer

It costs nothing at runtime and documents the intent the way override does in C++. I add it to every type whose whole point is to satisfy a particular interface, because refactors that rename or change a method signature otherwise break the contract silently.


Standard-library interfaces

The standard library is built on a handful of tiny interfaces, which is why so many packages compose with each other without adapters.

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)
}

One function that accepts io.Reader works with files, strings, buffers, network connections, and HTTP bodies:

package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
    "strings"
)

func processData(r io.Reader) error {
    data, err := io.ReadAll(r)
    if err != nil {
        return err
    }
    fmt.Println(string(data))
    return nil
}

func main() {
    if f, err := os.Open("file.txt"); err == nil {
        defer f.Close()
        _ = processData(f) // *os.File implements io.Reader
    }

    _ = processData(strings.NewReader("Hello from string"))       // *strings.Reader
    _ = processData(bytes.NewBufferString("Hello from buffer"))   // *bytes.Buffer
}

The Read contract is subtle and worth reading once in the docs: a reader may return n > 0 and a non-nil error (including io.EOF) in the same call, so callers should process the n bytes before looking at err. Helpers like io.ReadAll and io.Copy already handle this, which is a good reason to prefer them over hand-written read loops.

fmt.Stringer

In C++ you overload operator<< to control how a type prints. In Go you implement String() string, and the fmt package checks for it at runtime:

// C++: operator<< overloading
friend std::ostream& operator<<(std::ostream& os, const Person& p) {
    return os << p.name << " (" << p.age << ")";
}
type Person struct {
    Name string
    Age  int
}

// Satisfies fmt.Stringer
func (p Person) String() string {
    return fmt.Sprintf("%s (%d)", p.Name, p.Age)
}

func main() {
    fmt.Println(Person{Name: "Alice", Age: 30}) // "Alice (30)"
}

One gotcha: if String has a pointer receiver, fmt.Println(p) with a value p will not call it (see the method-set table above) and you get the default struct formatting instead. Another: calling fmt.Sprintf("%v", p) inside p.String() recurses forever, so format the fields, not the value itself.

error

type error interface {
    Error() string
}

Because error has one method, any type can be an error and carry structured fields:

type ValidationError struct {
    Field string
    Value string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s = %s", e.Field, e.Value)
}

func validate(email string) error {
    if !strings.Contains(email, "@") {
        return ValidationError{Field: "email", Value: email}
    }
    return nil
}

Callers recover the fields with errors.As, covered in #05.


Empty interface and type assertions

interface{} and any

// C++: void* — the cast is unchecked
void* ptr = new int(42);
int* p = static_cast<int*>(ptr);
// Go: any (alias for interface{}) — the dynamic type travels with the value
func printAny(v any) {
    fmt.Printf("Value: %v, Type: %T\n", v, v)
}

func main() {
    printAny(42)
    printAny("hello")
    printAny([]int{1, 2, 3})
}

The difference from void* matters: a Go any always knows its dynamic type, so a wrong narrowing is detected instead of silently reinterpreting memory.

Type assertion

func process(v any) {
    if s, ok := v.(string); ok {
        fmt.Println("String:", s)
        return
    }
    if i, ok := v.(int); ok {
        fmt.Println("Int:", i)
        return
    }
    fmt.Println("Unknown type")
}

Always use the two-value x, ok := v.(T) form unless a wrong type is a genuine programming error. The single-value form v.(T) panics on mismatch, which is roughly dynamic_cast on a reference throwing std::bad_cast.

Type switch

func describe(v any) {
    switch t := v.(type) {
    case string:
        fmt.Printf("String: %s (len %d)\n", t, len(t))
    case int:
        fmt.Printf("Int: %d\n", t)
    case bool:
        fmt.Printf("Bool: %t\n", t)
    case []int:
        fmt.Printf("Int slice: %v\n", t)
    case nil:
        fmt.Println("nil")
    default:
        fmt.Printf("Unknown: %T\n", t)
    }
}

Assertions can also target an interface type, not just a concrete one: if s, ok := v.(fmt.Stringer); ok { ... } asks “does this value have a String method?”. The standard library uses this to detect optional capabilities, for example io.Copy checks whether the source implements io.WriterTo and takes a faster path if it does.


Practical patterns

Using implicit implementation in real code

  • Define interfaces where they are consumed. A function that only reads should ask for io.Reader, not *os.File. Any type with the required methods can then be passed in.
  • Implementations need not import the interface, which makes test doubles and adapters cheap: a fake with one method is enough.
  • “Accept interfaces, return structs”: public APIs take narrow interfaces; constructors return concrete *T, so callers get the full API and can choose their own abstraction.
// net/http combines both: ResponseWriter is an interface, Request is a struct.
func handler(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "ok") // w also satisfies io.Writer
}

The mistake I see most often from C++ backgrounds (and made myself) is the opposite: writing a UserRepository interface with ten methods next to the one implementation, “for testability”, before anyone needs a second implementation. Every test then has to stub ten methods, and every new method touches every fake. Declaring a two-method interface in the consumer’s package, at the moment a test needs it, keeps both sides simpler.

Choosing between assertions and type switches

SituationRecommendation
One or two possible concrete typess, ok := v.(string)
Many types in one functionswitch t := v.(type) { ... }
Checking for an optional methodassert to a small interface: v.(io.WriterTo)
You control all the typesprefer a real interface method over switching on types

Prefer any (Go 1.18+) over interface{} in new code, and reach for it only when types genuinely vary at runtime, such as decoded JSON. If you find yourself type-switching over your own types, that is usually a missing interface method; with generics available, a type parameter is often a better fit than any for containers and helpers.

The nil interface trap

An interface is nil only when both its type and value words are nil. Returning a typed nil pointer through an interface produces a non-nil interface:

func find() error {
    var e *ValidationError = nil
    return e // interface holds (type=*ValidationError, value=nil)
}

func main() {
    if err := find(); err != nil {
        fmt.Println("not nil!") // this runs
    }
}

This one cost me an afternoon the first time: a function “returned nil” on success, yet every caller took the error branch. The fix is to return the literal nil for the success path rather than a nil pointer of a concrete error type. #05 covers it in the context of error handling.

Wiring Reader, Writer, and error together

  • io.Copy(dst, src) connects any reader to any writer: files, buffers, sockets, bytes.Reader.
  • io.MultiWriter fans one Write out to several writers (for example, a file and a hash).
  • io.TeeReader, bufio.NewReader, gzip.NewReader wrap one reader in another, which is the decorator pattern with no class hierarchy.
package main

import (
    "bytes"
    "fmt"
    "io"
    "strings"
)

func main() {
    r := strings.NewReader("hello")
    var buf bytes.Buffer
    if _, err := io.Copy(&buf, r); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(buf.String())
}

TL;DR: model I/O with io.Reader/io.Writer, failures with error, and use any plus narrowing only when types truly vary at runtime.


Interface design principles

Small interfaces

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

// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

The Go proverb is “the bigger the interface, the weaker the abstraction”. A one-method interface can be satisfied by a function adapter (like http.HandlerFunc), by a tiny test fake, or by types written years apart by people who never coordinated.

Anti-pattern: the “god” interface

// Hard to implement and mock
type Database interface {
    Connect() error
    Disconnect() error
    Query(sql string) ([]Row, error)
    Insert(table string, data map[string]any) error
    Update(table string, id int, data map[string]any) error
    Delete(table string, id int) error
    BeginTransaction() error
    CommitTransaction() error
    RollbackTransaction() error
}

Interface segregation

Split the surface by role and depend only on what each function uses:

type Querier interface {
    Query(sql string) ([]Row, error)
}
type Inserter interface {
    Insert(table string, data map[string]any) error
}
type Transactional interface {
    BeginTransaction() error
    CommitTransaction() error
    RollbackTransaction() error
}

func fetchData(q Querier) ([]Row, error) {
    return q.Query("SELECT * FROM users")
}

fetchData can now be tested with a struct that has a single Query method, and the concrete database type still satisfies all three interfaces without declaring any of them.


Exercises

Exercise 1: shapes

Add a third shape and iterate over a heterogeneous slice. Note that []Shape holds interface values, so each element carries its own dynamic type; %T prints it.

package main

import (
    "fmt"
    "math"
)

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Circle struct{ Radius float64 }

func (c Circle) Area() float64      { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }

type Rectangle struct{ Width, Height float64 }

func (r Rectangle) Area() float64      { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }

type Triangle struct{ A, B, C float64 } // side lengths

// Heron's formula
func (t Triangle) Area() float64 {
    s := (t.A + t.B + t.C) / 2
    return math.Sqrt(s * (s - t.A) * (s - t.B) * (s - t.C))
}
func (t Triangle) Perimeter() float64 { return t.A + t.B + t.C }

func printShapeInfo(s Shape) {
    fmt.Printf("Type: %T\n", s)
    fmt.Printf("Area: %.2f\n", s.Area())
    fmt.Printf("Perimeter: %.2f\n\n", s.Perimeter())
}

func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Rectangle{Width: 4, Height: 6},
        Triangle{A: 3, B: 4, C: 5},
    }
    for _, shape := range shapes {
        printShapeInfo(shape)
    }
}

Exercise 2: UpperWriter

A decorator that wraps any io.Writer. Because fmt.Fprintln and io.WriteString only need an io.Writer, they work with it unchanged. The method has a pointer receiver, so it is *UpperWriter (what NewUpperWriter returns) that satisfies io.Writer, and the blank assignment checks that at compile time.

package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
)

type UpperWriter struct{ w io.Writer }

var _ io.Writer = (*UpperWriter)(nil)

func NewUpperWriter(w io.Writer) *UpperWriter { return &UpperWriter{w: w} }

func (uw *UpperWriter) Write(p []byte) (n int, err error) {
    return uw.w.Write(bytes.ToUpper(p))
}

func main() {
    uw := NewUpperWriter(os.Stdout)
    fmt.Fprintln(uw, "hello world")      // HELLO WORLD
    io.WriteString(uw, "go is awesome\n") // GO IS AWESOME
}

bytes.ToUpper returns a slice of the same length for ASCII input, so returning the inner writer’s n is correct here. For arbitrary Unicode the upper-case form can differ in byte length, and a strict implementation would report len(p) on success to honor the io.Writer contract.

Exercise 3: combining interfaces

*os.File has Read, Write, and Close, so it satisfies the composed io.ReadWriteCloser without mentioning it.

package main

import (
    "fmt"
    "io"
    "os"
)

func processReadWriteCloser(rwc io.ReadWriteCloser) error {
    defer rwc.Close()
    _, err := rwc.Write([]byte("test data"))
    return err
}

func main() {
    f, err := os.Create("temp.txt")
    if err != nil {
        panic(err)
    }
    if err := processReadWriteCloser(f); err != nil {
        fmt.Println("write failed:", err)
    }
}

Exercise 4: JSON and type switch

Decoding into any gives you map[string]any for objects, []any for arrays, float64 for all numbers, string, bool, and nil. A type switch is the natural way to walk that shape. Note that 30 below arrives as float64, not int, which is a frequent surprise.

package main

import (
    "encoding/json"
    "fmt"
)

func parseJSON(jsonStr string) {
    var data any
    if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
        fmt.Println("Parse error:", err)
        return
    }
    switch v := data.(type) {
    case map[string]any:
        fmt.Println("Object:")
        for key, value := range v {
            fmt.Printf("  %s: %v (%T)\n", key, value, value)
        }
    case []any:
        fmt.Println("Array:")
        for i, item := range v {
            fmt.Printf("  [%d]: %v\n", i, item)
        }
    default:
        fmt.Printf("Other type: %T\n", v)
    }
}

func main() {
    parseJSON(`{"name":"Alice","age":30}`)
    parseJSON(`[1, 2, 3, 4, 5]`)
}

When the shape is known in advance, decoding into a struct is simpler and type-safe; any is for genuinely dynamic payloads.


Wrap-up: Day 7

  • Interfaces are method sets, satisfied implicitly, with no implements
  • Pointer-receiver methods are only in the method set of *T
  • io.Reader, io.Writer, fmt.Stringer, and error are the interfaces you will use daily
  • any plus the ok assertion form or a type switch for runtime-varying data
  • A typed nil pointer inside an interface is not a nil interface
  • Keep interfaces small and define them where they are consumed

C++ to Go

C++GoNotes
virtual functionsinterface methodsexplicit vs implicit
explicit inheritanceimplicit satisfactioninterface can be declared later, by the consumer
base-class pointerinterface value(type, value) pair
dynamic_casttype assertionok form instead of null/exception
multiple inheritanceinterface embeddingno diamond problem, only method sets
override keywordvar _ I = (*T)(nil)compile-time check by convention

End of week one

You now have syntax, data structures, methods, and interfaces. Week two starts with error handling and then moves to concurrency with goroutines and channels.


Series navigation

PreviousNext
#03 OOP & composition#05 Error handling

Go in 2 weeks: #01 · #02 · #03 · #04 · #05 · #06