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.
NewFoo(...)) creates instances.
Methods are functions with a "receiver" argument that lives between
func and the function name.2 · Try it — struct + method
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.
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
}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.
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.
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.
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))
}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.
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
}8 · Coming from another language?
| If you know… | The bridge |
|---|---|
| Python | struct ≈ @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 / TS | ≈ interface + a "class" that implements it. Tags ≈ @serializable decorators. |
| Rust | ≈ struct + 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*Tfor all. - Returning a struct by value when it has a mutex. Copying a
sync.Mutexproduces an unlocked copy — silent bug.go vetcatches this. - Forgetting that struct equality requires comparable fields. A struct with a slice field can't be compared with
==. - Capitalisation mismatches.
namewon't marshal to JSON unless you tag it;Namewill, 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)
- Build a Stack[int]. Struct with a slice field. Methods:
Push,Pop,Peek,Len. Use pointer receiver for mutators, value receiver forLen. - Add JSON tags. Take your
Stackfrom #1 and add tags so it serializes as{"items":[1,2,3]}. Test withjson.Marshal. - Embed a Logger. Define
type Logger struct{ prefix string }withLogfmethod. Embed it intoService. Callservice.Logf(...)directly. - Method on a named int.
type Status int. Add aString() stringmethod that returns"active","pending", etc. Use it withfmt.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() stringto your custom types so they print nicely.