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
implementskeyword, no base class - Implement the methods and the type satisfies
Shapeautomatically CircleandRectangledo not import or even know aboutShape, 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
virtualcall, 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 method | T has it in its method set | *T has it in its method set |
|---|---|---|
func (t T) M() | yes | yes |
func (t *T) M() | no | yes |
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
| Situation | Recommendation |
|---|---|
| One or two possible concrete types | s, ok := v.(string) |
| Many types in one function | switch t := v.(type) { ... } |
| Checking for an optional method | assert to a small interface: v.(io.WriterTo) |
| You control all the types | prefer 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.MultiWriterfans oneWriteout to several writers (for example, a file and a hash).io.TeeReader,bufio.NewReader,gzip.NewReaderwrap 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, anderrorare the interfaces you will use dailyanyplus theokassertion 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++ | Go | Notes |
|---|---|---|
virtual functions | interface methods | explicit vs implicit |
| explicit inheritance | implicit satisfaction | interface can be declared later, by the consumer |
| base-class pointer | interface value | (type, value) pair |
dynamic_cast | type assertion | ok form instead of null/exception |
| multiple inheritance | interface embedding | no diamond problem, only method sets |
override keyword | var _ 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
| Previous | Next |
|---|---|
| #03 OOP & composition | #05 Error handling |
Go in 2 weeks: #01 · #02 · #03 · #04 · #05 · #06
Related reading
- C++ virtual functions — the mechanism Go interfaces replace
- C++ vs Go — performance, concurrency, and how to choose
- Go context cancellation —
context.Contextis itself a small interface - Go web development —
http.Handlerand friends in practice - go.dev documentation and Effective Go: Interfaces