06 / 20 · Day 2
Day 2 · Concept 06

Structs & methods

Structs hold data. Methods attach to types. There are no classes — and yet Go's type system is more flexible than most class-based ones, because methods can attach to any named type (even type Celsius float64) and composition (struct embedding) replaces inheritance.


1 · The intuition

Go has no class keyword. A type is either a primitive (int, string), a composite (struct, map, slice), or a named type (type Foo X). Methods attach to any named type. Inheritance doesn't exist — you compose by embedding one type into another and inherit its method set.

The rule of three. A struct definition declares its fields. A constructor function (idiomatic: NewFoo(...)) creates instances. Methods are functions with a "receiver" argument that lives between func and the function name.

2 · Try it — struct + method

go main.go · the User type
package main

import "fmt"

// Type definition — capitalised fields are exported.
type User struct {
    Name  string
    Age   int
    email string  // lowercase = package-private
}

// Constructor — idiomatic name is "NewUser" returning *User
func NewUser(name string, age int) *User {
    return &User{Name: name, Age: age}
}

// Method on User (value receiver). u is a copy.
func (u User) Greet() string {
    return "Hello, " + u.Name + "!"
}

// Method on *User (pointer receiver). u points to the original.
func (u *User) SetEmail(e string) {
    u.email = e
}

func main() {
    alice := NewUser("Alice", 30)
    fmt.Println(alice.Greet())

    alice.SetEmail("alice@example.com")
    fmt.Printf("%+v
", alice)
}

3 · Value vs pointer receivers

Two ways to attach a method: func (u User) F() or func (u *User) F(). The first gets a copy; the second gets a pointer. The choice matters for mutation and for sharing.

go main.go · value vs pointer receiver
package main

import "fmt"

type Counter struct{ n int }

// Value receiver — works on a copy
func (c Counter) IncrCopy() { c.n++ }

// Pointer receiver — mutates the original
func (c *Counter) Incr() { c.n++ }

func main() {
    c := Counter{}
    c.IncrCopy()
    c.IncrCopy()
    fmt.Println(c.n)  // 0  — the copy was mutated, not c

    c.Incr()
    c.Incr()
    fmt.Println(c.n)  // 2  — pointer receiver actually mutates
}
When to use which. Pointer receiver: the method mutates, or the struct is large (avoid copy cost). Value receiver: small immutable data. Consistency rule: within a single type, all methods should use the same receiver kind. Mixing causes subtle interface-satisfaction bugs.

4 · Embedding — composition over inheritance

Embedding lets one struct include another anonymously. The embedded type's fields and methods become "promoted" — accessible directly on the outer struct.

go main.go · embedding
package main

import "fmt"

type Animal struct {
    Name string
}

func (a Animal) Greet() string { return "Hi, I am " + a.Name }

type Dog struct {
    Animal  // embedded — anonymous field
    Breed string
}

func main() {
    d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}

    fmt.Println(d.Name)     // promoted from Animal
    fmt.Println(d.Greet())  // promoted method
    fmt.Println(d.Breed)
}

Embedding is not inheritance. Dog is not a subtype of Animal. You can't pass a Dog where an Animal is expected unless an interface mediates. But the syntactic affordance — d.Greet() — feels like inheritance and saves a lot of forwarding code.

5 · Methods on non-struct types

You can attach methods to any named type — not just structs. This is the trick behind time.Duration, http.Status, and dozens of standard library types.

go main.go · method on a named type
package main

import "fmt"

type Celsius float64

func (c Celsius) Fahrenheit() float64 {
    return float64(c)*9/5 + 32
}

type ID int

func (i ID) String() string {
    return fmt.Sprintf("ID-%06d", i)
}

func main() {
    body := Celsius(37)
    fmt.Println(body.Fahrenheit())  // 98.6

    user := ID(42)
    fmt.Println(user)               // ID-000042  (Println calls String())
}

6 · Struct tags — the metadata layer

Tags are string literals after struct fields. They're metadata read at runtime via reflection. The standard use is JSON, XML, ORM mapping.

go main.go · struct tags for JSON
package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name      string `json:"name"`
    Age       int    `json:"age,omitempty"`
    SecretKey string `json:"-"`
}

func main() {
    u := User{Name: "Alice", Age: 30, SecretKey: "shhh"}
    b, _ := json.Marshal(u)
    fmt.Println(string(b))
}
The three common tag rules. json:"name" — rename the field. json:"age,omitempty" — omit if zero. json:"-" — never serialize. The encoding/json package reads these via reflection.

7 · From the wild

From Docker's container engine: classic struct + methods + embedding.

go docker · container/container.go (paraphrased)
type Container struct {
    StreamConfig *stream.Config

    State          *State
    Root           string
    BaseFS         containerfs.ContainerFS
    RWLayer        layer.RWLayer
    ID             string
    Created        time.Time
    Managed        bool
    Path           string
    Args           []string
    Config         *containertypes.Config
    NetworkSettings *network.Settings
    HostConfig     *containertypes.HostConfig

    AppliedVolumesFrom map[string]struct{}
    LogPath            string
    Name               string
    Driver             string

    sync.Mutex   // <-- embedded mutex, promotes Lock()/Unlock() to Container
}

// State management methods all live on *Container — they mutate.
func (c *Container) IsRunning() bool {
    c.Lock()
    defer c.Unlock()
    return c.State.Running
}
From the wild: github.com/moby/moby · Apache 2.0

8 · Coming from another language?

If you know…The bridge
Pythonstruct@dataclass. No __init__ — use a NewFoo function. No inheritance — use embedding.
Java / C#≈ a class with all-final fields and no constructor. Methods are written outside the type definition. Embedding ≠ extends; it's syntactic composition.
JavaScript / TSinterface + a "class" that implements it. Tags ≈ @serializable decorators.
Ruststruct + impl. The pointer/value receiver distinction is Rust's &self vs self vs &mut self simplified.
C++≈ a POD struct with member functions. Embedding ≠ public inheritance; it's more like composition with method-name promotion.

9 · Common mistakes

  • Mixing value and pointer receivers on one type. Pick one and stick to it. The official advice: if any method needs *T, use *T for all.
  • Returning a struct by value when it has a mutex. Copying a sync.Mutex produces an unlocked copy — silent bug. go vet catches this.
  • Forgetting that struct equality requires comparable fields. A struct with a slice field can't be compared with ==.
  • Capitalisation mismatches. name won't marshal to JSON unless you tag it; Name will, even without a tag.
  • Methods on slice types — fine. Methods on map types — also fine. Embedding via pointer (*Animal) — works, but the embedded value can be nil; check for it.

10 · Exercises (~15 min)

  1. Build a Stack[int]. Struct with a slice field. Methods: Push, Pop, Peek, Len. Use pointer receiver for mutators, value receiver for Len.
  2. Add JSON tags. Take your Stack from #1 and add tags so it serializes as {"items":[1,2,3]}. Test with json.Marshal.
  3. Embed a Logger. Define type Logger struct{ prefix string } with Logf method. Embed it into Service. Call service.Logf(...) directly.
  4. Method on a named int. type Status int. Add a String() string method that returns "active", "pending", etc. Use it with fmt.Println(Status(1)).

11 · When it clicks

  • You write NewX() by reflex; the constructor returns *X.
  • You can predict whether a method mutates from its receiver signature alone.
  • You reach for embedding when you'd reach for inheritance in another language.
  • You add String() string to your custom types so they print nicely.
Found this useful?