Chapter 6

Structs, Methods and Interfaces

Structs, Methods and Interfaces

Go has no classes, no inheritance, no virtual methods โ€” but it has structs, methods, and interfaces. The combination of these three forms Go's "object-oriented" paradigm, or more accurately, Go's composition-based type system.

This isn't a compromise or simplification โ€” it's a carefully considered design philosophy: composition over inheritance, interfaces over abstract classes, implicit satisfaction over explicit declaration (Rob Pike, "Go Proverbs", 2015). The result is a type system that's both simple and powerful โ€” simple enough for beginners to learn quickly, powerful enough to build million-line projects like Kubernetes.

This chapter starts from struct definitions, dives into memory layout and alignment rules, explores the internal representation of interfaces (iface/eface), and ultimately reveals the nil interface traps that have caught countless developers.

Level 1: What You Need to Know

Struct Definition

Structs are the foundation of custom types in Go. They combine multiple fields into a logical unit:

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
    IsActive  bool
}

// Initialization approaches
u1 := User{ID: 1, Name: "Alice", Email: "[email protected]"}  // Recommended: named fields
u2 := User{1, "Bob", "[email protected]", time.Now(), true}      // Not recommended: positional
var u3 User   // Zero value: all fields at their respective zero values

// Access and modify
u1.Name = "Alice Smith"
fmt.Println(u1.Name)

Why recommend named field initialization? Positional initialization is coupled to field declaration order โ€” if you later add a new field in the middle of the struct, all positional initializations either fail to compile or worse, silently assign to the wrong fields. Named field initialization is unaffected by field order, and unassigned fields automatically get zero values.

Struct Export Rules

Go uses initial letter capitalization to control visibility โ€” uppercase is exported (public), lowercase is unexported (package-private):

type User struct {
    ID    int64  // Exported: accessible from other packages
    Name  string // Exported
    email string // Unexported: only accessible within current package
}

This rule applies to the struct type itself: type User struct is exported, type user struct is unexported.

Be aware when embedding unexported fields:

// In package models
type baseModel struct {
    id        int64
    createdAt time.Time
}

type User struct {
    baseModel  // Embedding unexported type
    Name string
}

// In other packages
u := models.User{Name: "Alice"}
// u.id is inaccessible (unexported field)
// u.baseModel is inaccessible (unexported type)

Methods: Value Receivers vs Pointer Receivers

Methods are functions bound to a type. Go associates functions with types through receivers:

type Rectangle struct {
    Width, Height float64
}

// Value receiver: doesn't modify the original
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Pointer receiver: can modify the original
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

Rules for choosing value vs pointer receivers:

Use pointer receivers when (more common):

  1. The method needs to modify the receiver
  2. The struct is large, avoiding copy overhead
  3. Consistency: if one method uses a pointer receiver, others should too

Use value receivers when:

  1. The struct is small (e.g., time.Time, Point{X, Y int})
  2. The type is immutable โ€” all methods are read-only
  3. The type is a map, func, or channel (they're already reference types)
// Go's syntactic sugar: automatic address-taking/dereferencing
rect := Rectangle{Width: 10, Height: 5}
rect.Scale(2)  // equivalent to (&rect).Scale(2), Go auto-takes address

ptr := &Rectangle{Width: 10, Height: 5}
ptr.Area()     // equivalent to (*ptr).Area(), Go auto-dereferences

But this sugar has an important limitation โ€” non-addressable values cannot call pointer receiver methods:

// Compile error
Rectangle{Width: 10, Height: 5}.Scale(2)
// Rectangle literal is non-addressable (rvalue), cannot take address

// Fix
r := Rectangle{Width: 10, Height: 5}
r.Scale(2) // r is a variable, addressable

Interfaces: Implicit Implementation

Go interfaces are implicitly implemented โ€” as long as a type has all methods an interface requires, it automatically satisfies that interface without an implements keyword:

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
}

// Circle automatically satisfies Shape, no explicit declaration needed
var s Shape = Circle{Radius: 5}
fmt.Println(s.Area())      // 78.54...
fmt.Println(s.Perimeter()) // 31.42...

Why implicit implementation? Explicit implementation (like Java's implements) requires implementers to know about the interface's existence. In large codebases this creates circular dependencies โ€” the package defining the interface and the package implementing it must know about each other. Implicit implementation decouples these: interfaces are defined where they're used, implementations are where they're provided, and neither needs to import the other (Rob Pike, "Go at Google: Language Design in the Service of Software Engineering", 2012).

This reflects an important Go design philosophy: interfaces should be defined by consumers, not producers.

// io package defines the Reader interface
type Reader interface {
    Read(p []byte) (n int, err error)
}

// os.File satisfies io.Reader, but os doesn't import io
// bytes.Buffer satisfies io.Reader, but bytes doesn't import io
// net.Conn satisfies io.Reader, but net doesn't import io

Common Interfaces

The Go standard library defines some critically important interfaces:

// io.Reader โ€” abstraction for all readable data sources
type Reader interface {
    Read(p []byte) (n int, err error)
}

// io.Writer โ€” abstraction for all writable destinations
type Writer interface {
    Write(p []byte) (n int, err error)
}

// fmt.Stringer โ€” custom string representation
type Stringer interface {
    String() string
}

// error โ€” Go's error interface
type error interface {
    Error() string
}

// sort.Interface โ€” sortable collection
type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

Interface Composition

Go interfaces can be composed into larger interfaces through embedding:

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

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

type ReadWriter interface {
    Reader
    Writer
}

// Equivalent to
type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

The Go community's interface design principle: "Accept interfaces, return structs" โ€” accept interfaces as parameters (flexibility), return concrete types (predictability). And interfaces should be as small as possible โ€” single-method interfaces are the most valuable.

Type Assertions and Type Switches

When you have an interface value but need to access its underlying concrete type:

var s Shape = Circle{Radius: 5}

// Type assertion
c, ok := s.(Circle)
if ok {
    fmt.Println("It's a circle with radius", c.Radius)
}

// Unsafe type assertion (panics if type doesn't match)
c := s.(Circle) // panics if s isn't a Circle

// Type switch
func describe(s Shape) string {
    switch v := s.(type) {
    case Circle:
        return fmt.Sprintf("Circle with radius %.2f", v.Radius)
    case Rectangle:
        return fmt.Sprintf("Rectangle %v x %v", v.Width, v.Height)
    default:
        return "Unknown shape"
    }
}

Empty Interface interface{} and any

The empty interface has no methods, so all types satisfy it:

var x interface{} = 42
x = "hello"
x = []int{1, 2, 3}
// any value can be assigned to interface{}

Go 1.18 introduced any as a type alias for interface{}:

// Go 1.18+
type any = interface{}

// These are completely equivalent
func printAnything(v any) {
    fmt.Println(v)
}

When to use the empty interface?

As little as possible. The empty interface loses type safety โ€” you must use type assertions or reflection to use the value. Reasonable use cases:

With Go 1.18+ generics, many scenarios that previously required interface{} can use type parameters instead, gaining compile-time type checking.

Level 2: How It Works Under the Hood

Struct Memory Layout

Go struct fields are laid out in memory in declaration order, but with alignment and padding:

type Example struct {
    a bool    // 1 byte
    // 7 bytes padding
    b int64   // 8 bytes
    c bool    // 1 byte
    // 7 bytes padding
    d int64   // 8 bytes
}
// sizeof(Example) = 32 bytes

type ExampleOptimized struct {
    b int64   // 8 bytes
    d int64   // 8 bytes
    a bool    // 1 byte
    c bool    // 1 byte
    // 6 bytes padding
}
// sizeof(ExampleOptimized) = 24 bytes โ€” saves 8 bytes

Why is alignment needed? When a CPU loads data from memory, it operates in "word size" units โ€” a 64-bit CPU loads in 8-byte units. If an int64 value crosses an 8-byte boundary (e.g., starting at address 5), the CPU needs two memory accesses to read the complete value, then stitch them together โ€” this is 2-10x slower than aligned access (depending on CPU architecture).

Go's alignment rules:

Rule of thumb for field ordering optimization: Arrange fields from largest to smallest size to minimize padding:

// Tool: use fieldalignment for automatic detection
// go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
// fieldalignment -fix ./...

In practice, whether to optimize depends on how frequently the struct is used. If only a handful of instances are created, optimization is pointless. But if creating millions of instances (e.g., one per HTTP request), saving 8 bytes means saving 8MB of memory.

Interface Internal Representation

Go interfaces have two runtime representations:

eface (empty interface): Used for interface{}/any

// runtime/runtime2.go
type eface struct {
    _type *_type      // type information pointer
    data  unsafe.Pointer // data pointer
}

iface (non-empty interface): Used for interfaces with methods

type iface struct {
    tab  *itab         // interface table (type info + method list)
    data unsafe.Pointer // data pointer
}

type itab struct {
    inter *interfacetype // interface type descriptor
    _type *_type         // concrete type descriptor
    hash  uint32         // type hash for fast type assertions
    _     [4]byte
    fun   [1]uintptr     // method table (variable-length, actual size = interface method count)
}

When you assign a concrete value to an interface variable, the runtime:

var s Shape = Circle{Radius: 5}
  1. Allocates a copy of Circle{Radius: 5} on the heap (if the value is too large to fit in pointer-sized space)
  2. Looks up or creates the itab (combination of Shape interface + Circle type)
  3. Fills in iface: tab points to itab, data points to Circle data on heap

itab is globally cached โ€” the same (interface type, concrete type) pair only creates one itab, subsequent uses look it up directly. This makes the amortized cost of interface assignment very low.

Interface Method Call Overhead

Calling a method through an interface requires one extra level of indirection compared to a direct call:

// Direct call
c := Circle{Radius: 5}
c.Area() // compiler directly generates CALL Circle.Area

// Interface call
var s Shape = c
s.Area() // runtime: look up Area's address from s.tab.fun table, then CALL

Interface call overhead:

  1. Load itab pointer (one memory access)
  2. Get method address from itab.fun table (one memory access)
  3. Indirect call (CPU branch prediction hit rate may be lower)

In microbenchmarks, interface calls are approximately 2-3 ns slower than direct calls. But in real applications, this overhead is usually negligible โ€” unless on an extremely hot path with billions of calls per second.

Compiler optimization: If the compiler can determine an interface variable's concrete type at compile time (devirtualization), it optimizes the interface call into a direct call. Go 1.20+'s PGO (Profile-Guided Optimization) can use runtime profile information for more aggressive devirtualization.

Interface Zero Value and the nil Trap

This is one of Go's most notorious pitfalls:

type MyError struct {
    Code    int
    Message string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

func getError(hasError bool) error {
    var err *MyError = nil  // err is a nil pointer of type *MyError
    if hasError {
        err = &MyError{Code: 404, Message: "not found"}
    }
    return err  // returns interface{error} value
}

func main() {
    err := getError(false)
    if err != nil {
        fmt.Println("Error:", err) // This executes! Even though logically there's no error
    }
}

Why is err != nil true?

When return err executes, err is a nil pointer of type *MyError. When assigned to the error interface, an iface is created:

An interface value only equals nil when both tab and data are nil. Here tab has a value (recording concrete type information), so the interface value isn't nil.

Fix:

func getError(hasError bool) error {
    if hasError {
        return &MyError{Code: 404, Message: "not found"}
    }
    return nil  // return nil directly, don't go through a concrete type variable
}

Rule: Never assign a concrete type's nil pointer to an interface variable and then check if the interface is nil. If a function returns an interface type, directly return nil in the "no error" branch.

Type Embedding (Composition)

Go doesn't support inheritance but provides type embedding for code reuse:

type Animal struct {
    Name string
    Age  int
}

func (a Animal) Speak() string {
    return a.Name + " makes a sound"
}

type Dog struct {
    Animal  // Embedding Animal (not a field name)
    Breed string
}

func main() {
    d := Dog{
        Animal: Animal{Name: "Rex", Age: 3},
        Breed:  "Labrador",
    }
    
    // Can directly access embedded type's fields and methods
    fmt.Println(d.Name)    // "Rex" (equivalent to d.Animal.Name)
    fmt.Println(d.Speak()) // "Rex makes a sound"
    fmt.Println(d.Breed)   // "Labrador"
}

Embedding isn't inheritance โ€” Dog "has an" Animal, not "is an" Animal:

// Dog cannot be assigned to a variable of type Animal
var a Animal = d  // compile error!

// But Dog can satisfy interfaces corresponding to Animal's method set
type Speaker interface {
    Speak() string
}
var s Speaker = d  // works! Dog gained Speak() through embedding

Method promotion rules for embedding:

type Dog struct {
    Animal
    Breed string
}

// "Override" Animal's Speak method
func (d Dog) Speak() string {
    return d.Name + " barks!"
}

Value Receivers and Interface Satisfaction

A subtle but important rule โ€” about method sets:

type Sizer interface {
    Size() int
}

type Resizer interface {
    Resize(int)
}

type Widget struct {
    width int
}

func (w Widget) Size() int { return w.width }    // value receiver
func (w *Widget) Resize(n int) { w.width = n }   // pointer receiver

var s Sizer = Widget{width: 10}    // OK: Widget has Size()
var r Resizer = &Widget{width: 10} // OK: *Widget has Resize()
var r2 Resizer = Widget{width: 10} // Compile error! Widget doesn't have Resize()

Why doesn't Widget satisfy Resizer? Because Widget values might be non-addressable (like literals or map values). If non-addressable values could call pointer receiver methods, the compiler couldn't obtain a pointer and the call would fail. So Go prevents this at compile time.

Level 3: What the Specification Says

Method Set Specification

The Go Language Specification defines method sets precisely:

The method set of a type determines the interfaces that the type implements and the methods that can be called using a receiver of that type.

Method set rules from the specification:

  • The method set of a defined type T consists of all methods declared with receiver type T.
  • The method set of a pointer to a defined type T (that is, *T) consists of all methods declared with receiver *T or T.
  • The method set of a type includes the method set of the types it embeds (taking into account pointer embedding rules).

An important corollary โ€” whether an interface stores a value or pointer determines which methods can be called:

type Stringer interface {
    String() string
}

type MyType struct{ name string }
func (m *MyType) String() string { return m.name }

var s Stringer = &MyType{name: "hello"} // OK
var s2 Stringer = MyType{name: "hello"} // Error: MyType doesn't have String()

Interface Type Specification

An interface type specifies a method set called its interface. A variable of interface type can store a value of any type with a method set that is a superset of the interface. Such a type is said to implement the interface.

"Method set that is a superset" โ€” implementing an interface doesn't require an exact method set match, a superset suffices. Types can have more methods than the interface requires.

Interface embedding specification (Go 1.14+):

// Before Go 1.14: interface embedding didn't allow method overlap
type A interface { Foo() }
type B interface { Foo() }
type C interface { A; B } // Go 1.13: compile error (duplicate method)

// Go 1.14+: overlap allowed as long as method signatures are identical
type C interface { A; B } // OK

Struct Specification

A struct is a sequence of named elements, called fields, each of which has a name and a type.

Anonymous field (embedding) specification:

A field declared with a type but no explicit field name is called an embedded field. An embedded field must be specified as a type name T or as a pointer to a non-interface type name *T, and T itself may not be a pointer type.

Key restrictions:

Selector expression specification:

For a value x of type T or *T where T is not a pointer or interface type, x.f denotes the field or method at the shallowest depth in T where there is such an f.

"Shallowest depth" โ€” if outer and embedded levels have same-named fields/methods, the shallowest (outer) takes precedence.

Interface Value Comparison Specification

Interface values are comparable. Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have value nil.

Note "identical dynamic types" โ€” if two interface values have different dynamic types, they're definitely not equal (even if underlying values look the same):

var a interface{} = int(1)
var b interface{} = int64(1)
fmt.Println(a == b) // false! different types

Another danger โ€” if the dynamic type isn't comparable, comparison panics:

var a interface{} = []int{1, 2, 3}
var b interface{} = []int{1, 2, 3}
fmt.Println(a == b) // panic: comparing uncomparable type []int

Type Assertion Specification

For an interface type I, x.(T) asserts that x is not nil and that the value stored in x is of type T.

The specification distinguishes two forms:

// Single-value form: panics if assertion fails
v := x.(T)

// Two-value form: if assertion fails, ok=false, v is T's zero value
v, ok := x.(T)

Empty Struct Specification Behavior

type Empty struct{}

The specification states:

A struct or array type has size zero if it contains no fields (or elements, respectively) that have non-zero size. Two distinct zero-size variables may have the same address in memory.

Empty structs have size 0, but they're still legitimate types. Two different empty struct variables may (but aren't guaranteed to) have the same address. In practice, the compiler typically has all empty structs point to the same address: runtime.zerobase.

a := struct{}{}
b := struct{}{}
fmt.Println(&a == &b) // might be true or false (depends on compiler optimization)

Method Values and Method Expressions

The specification defines two ways to use methods:

type T struct{ name string }
func (t T) Hello() string { return "Hello, " + t.name }

// Method value: function with bound receiver
t := T{name: "World"}
f := t.Hello   // f's type is func() string
f()            // "Hello, World"

// Method expression: unbound function, receiver as first parameter
g := T.Hello   // g's type is func(T) string
g(T{name: "Go"}) // "Hello, Go"

Method values are closures under the hood โ€” they capture the receiver. This means method values have all closure properties:

t := T{name: "World"}
f := t.Hello  // captures a copy of t (value receiver)
t.name = "Changed"
f()  // still returns "Hello, World"

pt := &T{name: "World"}
g := pt.Hello  // pointer receiver: captures the pointer
pt.name = "Changed"
g()  // returns "Hello, Changed"

Interface Internal Optimization

When an interface value stores a scalar type that doesn't exceed pointer size, the Go runtime doesn't heap-allocate โ€” the value is stored directly in the iface/eface's data field (rather than storing a pointer to heap memory):

var x interface{} = 42   // int stored directly in data field (no heap allocation)
var y interface{} = "hi" // string is 16 bytes (2 words), needs heap allocation

This optimization was introduced in Go 1.15 (Keith Randall, "cmd/compile: store concrete types directly in interfaces when they fit", 2020), reducing GC pressure for small-value interface assignments.

But note: this is an implementation detail, not a language guarantee. Code should not depend on this optimization.

Level 4: Edge Cases and Pitfalls

Interview Question: The nil Interface Trap

Question: What does the following code output?

type Animal interface {
    Speak() string
}

type Dog struct{}
func (d *Dog) Speak() string { return "Woof" }

func GetAnimal(want bool) Animal {
    var d *Dog
    if want {
        d = &Dog{}
    }
    return d
}

func main() {
    a := GetAnimal(false)
    fmt.Println(a == nil)
    fmt.Println(a)
    
    if a != nil {
        fmt.Println(a.Speak()) // What happens here?
    }
}

Answer:

false
<nil>

Will a.Speak() panic? No!

a is an interface value internally storing (*Dog)(nil). When a.Speak() is called:

  1. Look up Speak method address from itab โ†’ find (*Dog).Speak
  2. Call (*Dog).Speak with receiver d being nil
  3. (*Dog).Speak doesn't dereference d internally (just returns a string constant), so no panic

A method call on a nil pointer receiver only panics if the method implementation accesses the receiver's fields (like d.Name). This demonstrates that method calls on nil pointer receivers don't necessarily panic โ€” it depends on whether the method accesses receiver fields.

Full output:

false
<nil>
Woof

Interview Question: Type Assertion vs Type Switch

Question: What's the difference between these two approaches?

// Approach 1: chained type assertions
func process(v interface{}) {
    if s, ok := v.(string); ok {
        handleString(s)
    } else if i, ok := v.(int); ok {
        handleInt(i)
    } else if f, ok := v.(float64); ok {
        handleFloat(f)
    }
}

// Approach 2: type switch
func process(v interface{}) {
    switch x := v.(type) {
    case string:
        handleString(x)
    case int:
        handleInt(x)
    case float64:
        handleFloat(x)
    }
}

Answer:

Functionally equivalent, but with subtle differences:

  1. Performance: Type switches can be optimized by the compiler into jump tables (similar to switch-case optimization), while chained assertions perform runtime type checks each time
  2. Readability: Type switches are clearer
  3. Scope: In a type switch, x automatically has the corresponding concrete type in each case, no explicit assertion needed
  4. Composability: Type switches can list multiple types in a case: case int, int64:

A special type switch behavior:

switch v := x.(type) {
case int, int64:
    // v's type is interface{}! Because it can't be determined which
case string:
    // v's type is string
}

Interview Question: When to Use Pointer Receivers

Question: What's wrong with this code?

type Counter struct {
    count int
}

func (c Counter) Increment() {
    c.count++
}

func main() {
    c := Counter{}
    c.Increment()
    c.Increment()
    fmt.Println(c.count) // ?
}

Answer: Outputs 0.

Increment uses a value receiver, so each call operates on a copy of Counter. Modifications to the copy don't affect the original. Should use a pointer receiver:

func (c *Counter) Increment() {
    c.count++
}

Extension: What if Counter is stored in an interface?

type Incrementer interface {
    Increment()
}

// Value receiver version
func (c Counter) Increment() { c.count++ }

var inc Incrementer = Counter{}  // compiles
inc.Increment()                  // operates on internal copy, not visible externally

// Pointer receiver version
func (c *Counter) Increment() { c.count++ }

var inc Incrementer = Counter{}   // compile error! Counter doesn't have Increment()
var inc Incrementer = &Counter{}  // OK
inc.Increment()                   // modifies the Counter inside the interface

Pitfall: nil Pointer Calls Through Interface Values

type Logger interface {
    Log(msg string)
}

type FileLogger struct {
    file *os.File
}

func (l *FileLogger) Log(msg string) {
    // If l.file is nil, this panics
    fmt.Fprintln(l.file, msg)
}

func NewLogger(path string) Logger {
    if path == "" {
        return nil  // Correct: returns nil interface
    }
    f, err := os.Create(path)
    if err != nil {
        return nil  // Correct
    }
    return &FileLogger{file: f}
}

// But if written like this:
func NewLoggerBad(path string) Logger {
    var l *FileLogger  // nil pointer
    if path != "" {
        f, _ := os.Create(path)
        l = &FileLogger{file: f}
    }
    return l  // Even though l is nil, the interface value isn't nil!
}

Pitfall: Embedding Interfaces Causing Panics

type Handler interface {
    Handle(r *http.Request) error
}

type BaseHandler struct {
    Handler  // Embedding an interface!
}

// If no Handler implementation is provided for BaseHandler...
h := BaseHandler{}
h.Handle(req)  // panic: nil pointer dereference
// Because the embedded Handler interface value is nil

Embedding interfaces is useful in certain design patterns (like partial implementation/mocking), but you must ensure the interface field is initialized.

Real-world Case: Interface Design in database/sql

The Go standard library's database/sql is a paragon of interface design:

// driver package defines interfaces
type Driver interface {
    Open(name string) (Conn, error)
}

type Conn interface {
    Prepare(query string) (Stmt, error)
    Close() error
    Begin() (Tx, error)
}

// Users depend only on interfaces
// Concrete implementations (mysql, postgres, sqlite) register themselves
func Register(name string, driver driver.Driver) {
    // ...
}

This design embodies all of Go's interface philosophy principles:

  1. Interfaces are small and focused (2-3 methods each)
  2. Interfaces are defined at the use site (database/sql package), not the provider (mysql driver package)
  3. Implicit satisfaction โ€” any driver just needs to implement these methods to register
  4. Complex behavior built through interface composition

Performance Pitfall: Interface Slice Conversion

// Cannot directly assign []string to []interface{}
names := []string{"Alice", "Bob", "Charlie"}

// Compile error
var ifaces []interface{} = names

// Must convert element by element
ifaces := make([]interface{}, len(names))
for i, n := range names {
    ifaces[i] = n
}

Why not allowed? Because []string and []interface{} have completely different memory layouts:

Although each element is the same size (both 16 bytes), the internal structure differs โ€” reading a string header as an eface would interpret completely wrong type information.

Go 1.18 generics partially alleviate this problem:

func toAny[T any](s []T) []any {
    result := make([]any, len(s))
    for i, v := range s {
        result[i] = v
    }
    return result
}

Design Pattern: Interface Compliance Check

Compile-time check that a type implements an interface:

// Compile-time check that *FileLogger implements Logger
var _ Logger = (*FileLogger)(nil)

// If *FileLogger doesn't implement all of Logger's methods, compilation fails

This line has no runtime overhead (compiler optimizes away the nil assignment), but provides compile-time guarantees. This technique is widely used in the Go standard library and major open-source projects.

Design Pattern: Option Interface

Besides the functional options pattern, interfaces can also be used for configuration:

type Option interface {
    apply(*Server)
}

type portOption struct{ port int }
func (o portOption) apply(s *Server) { s.port = o.port }

type timeoutOption struct{ timeout time.Duration }
func (o timeoutOption) apply(s *Server) { s.timeout = o.timeout }

func WithPort(port int) Option { return portOption{port: port} }
func WithTimeout(t time.Duration) Option { return timeoutOption{timeout: t} }

Difference from the functional options pattern: interface options can implement more complex logic (like validation, mutually exclusive option detection) and can be serialized.

Advanced Topic: A Subtle Difference Between interface{} and any

In type constraints, any and interface{} have subtle differences (Go 1.18+):

// As type constraints
type MyConstraint interface {
    ~int | ~string
}

// The following are completely equivalent in regular code
var x any = 42
var y interface{} = 42

// In generic constraints, any and interface{} behave identically
func foo[T any](v T) {}       // T can be any type
func bar[T interface{}](v T) {} // same as above

In practice, in Go 1.18+, any is simply an alias for interface{} (type any = interface{}), usable interchangeably anywhere. The Go team recommends new code use any uniformly for brevity.

Deep Interview Question: Why Doesn't Go Support Covariant Return Types?

type Animal interface {
    Child() Animal
}

type Dog struct{}
func (d Dog) Child() Dog { return Dog{} } // Doesn't satisfy Animal interface!
// Must write:
func (d Dog) Child() Animal { return Dog{} }

Go doesn't support covariance because:

  1. Interface satisfaction is based on exact method signature matching
  2. Implementing covariance requires the compiler to do additional type conversion wrapping in interface method tables
  3. Go's design philosophy is "simple is better" โ€” covariance adds language complexity for limited use cases

This differs from Java (supports covariant return types, Java 5+) and C# (supports covariant generic interfaces). Go chose simpler rules, at the cost of requiring slightly more code for certain design patterns.


This chapter deeply explored the three pillars of Go's type system. Structs provide data organization, methods provide behavior binding, and interfaces provide abstract polymorphism โ€” all three work together through composition rather than inheritance. Understanding the internal representation of interfaces (iface/eface/itab) is key to avoiding nil interface traps; understanding method set rules is the foundation for correct pointer receiver usage; and understanding memory layout is the prerequisite for performance optimization.

Go's type system appears simple โ€” no inheritance hierarchies, no generics (until 1.18), no metaprogramming โ€” but it's precisely this simplicity that allows million-line codebases to remain comprehensible to humans. This is Go's design philosophy: Clear is better than clever.

Rate this chapter
4.7  / 5  (70 ratings)

๐Ÿ’ฌ Comments