Step 03 of 24
Structs, methods & interfaces
Model data with structs, attach behaviour with methods, decouple code with interfaces and write reusable generic functions.
Go has no classes and no inheritance. Instead it gives you three small tools that combine well: structs to group data, methods to attach behaviour to types, and interfaces to describe behaviour without naming a concrete type. Add generics for code that works across many types, and you have everything you need to model a backend's domain. This lesson covers each tool, how they fit together, and the traps that catch newcomers.
Structs: grouping data
A struct is a typed collection of named fields:
type User struct {
ID int
Email string
Name string
Admin bool
Tags []string
}Create values with a composite literal. Always name the fields; positional literals break as soon as someone adds a field.
u := User{ID: 1, Email: "[email protected]", Name: "Ana"}
u.Admin = true
fmt.Println(u.Name, u.Admin, len(u.Tags)) // Ana true 0
// The zero value: every field is its own zero value.
var empty User
fmt.Printf("%+v\n", empty) // {ID:0 Email: Name: Admin:false Tags:[]}Structs are values. Assigning one or passing it to a function copies every field. To share a single struct, use a pointer, written *User. The & operator takes the address of a value:
func promote(u *User) { u.Admin = true } // changes the caller's struct
bo := User{Name: "Bo"}
promote(&bo)
fmt.Println(bo.Admin) // true
p := &User{Name: "Cy"} // pointer to a new struct
p.Name = "Cyrus" // Go dereferences automatically: no (*p).Name neededField names follow the export rule: Email is visible to other packages, a field named email is not. This matters later, because packages like encoding/json can only see exported fields.
Methods and receivers
A method is a function with a receiver, the value it is called on, declared before the name:
type Rect struct{ W, H float64 }
func (r Rect) Area() float64 { return r.W * r.H } // value receiver
func (r *Rect) Scale(f float64) { // pointer receiver
r.W *= f
r.H *= f
}
func main() {
r := Rect{W: 2, H: 3}
r.Scale(2) // Go takes &r for you
fmt.Println(r.Area()) // 24
}A value receiver gets a copy, so it cannot change the original. A pointer receiver gets the address, so it can. Use a pointer receiver when the method must modify the receiver, when the struct is large, or when the struct contains something that must not be copied, such as a sync.Mutex. If any method needs a pointer receiver, give all of the type's methods pointer receivers so the type behaves consistently.
You can define methods on any named type declared in your package, not just structs:
type Celsius float64
func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 + 32 }Go has no constructors. By convention, a function named NewX builds a valid X, filling in defaults and returning a pointer when the value will be shared or mutated:
type Counter struct {
name string
count int
}
func NewCounter(name string) *Counter { return &Counter{name: name} }
func (c *Counter) Inc() { c.count++ }
func (c *Counter) Value() int { return c.count }Embedding: composition, not inheritance
A struct can embed another type by listing it without a field name. The embedded type's fields and methods are promoted, so you can use them as if they belonged to the outer struct:
type Timestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
}
func (t *Timestamps) Touch() { t.UpdatedAt = time.Now() }
type Post struct {
Timestamps // embedded
ID int
Title string
}
func main() {
var p Post
p.Touch() // promoted method
fmt.Println(!p.UpdatedAt.IsZero()) // promoted field: true
fmt.Println(p.Timestamps.UpdatedAt) // still reachable by its type name
}This is composition. A Post is not a Timestamps; you cannot pass a Post where a Timestamps is expected. The compiler simply forwards p.Touch() to p.Timestamps.Touch(). If the outer struct defines a field or method with the same name, the outer one wins.
Interfaces: behaviour as a contract
An interface type lists method signatures. Any type that has those methods automatically satisfies the interface; there is no implements keyword.
type Notifier interface {
Notify(to, msg string) error
}
type EmailNotifier struct{ From string }
func (e EmailNotifier) Notify(to, msg string) error {
fmt.Printf("email from %s to %s: %s\n", e.From, to, msg)
return nil
}
type LogNotifier struct{}
func (LogNotifier) Notify(to, msg string) error {
fmt.Println("log:", to, msg)
return nil
}
func Welcome(n Notifier, email string) error {
return n.Notify(email, "Welcome aboard!")
}
func main() {
Welcome(EmailNotifier{From: "[email protected]"}, "[email protected]")
Welcome(LogNotifier{}, "[email protected]") // e.g. in tests or local dev
}Welcome neither knows nor cares which notifier it gets. That is the point: the code that uses behaviour declares a small interface for exactly what it needs, and any type can plug in. The standard library is built this way. io.Reader has one method, Read(p []byte) (n int, err error), and files, network connections, HTTP request bodies and in-memory buffers all satisfy it. fmt.Stringer has one method, String() string, which fmt uses when printing your types.
Guidelines that keep interfaces useful:
- Keep them small. One to three methods is typical. Big interfaces are hard to implement and to fake in tests.
- Define them where they are used, not next to the implementation. A package that sends welcome emails declares
Notifier; the email package just exports a concrete type. - Accept interfaces, return concrete types.
NewCounterreturns*Counter, not an interface, so callers get the full type.
Method sets: the pointer trap
If a method has a pointer receiver, only the pointer type has that method. A plain value does not satisfy the interface:
type Shape interface{ Area() float64 }
type Square struct{ S float64 }
func (s *Square) Area() float64 { return s.S * s.S }
var _ Shape = &Square{S: 2} // ok
// var _ Shape = Square{S: 2} // compile error: Area method has pointer receiverThe line var _ Shape = &Square{} is also a handy idiom: a compile-time check that a type satisfies an interface.
Type assertions, type switches and nil interfaces
An interface value holds two things: a concrete type and a value of that type. A type assertion extracts the concrete value; use the two-result form so a mismatch returns false instead of panicking:
var n Notifier = EmailNotifier{From: "[email protected]"}
if e, ok := n.(EmailNotifier); ok {
fmt.Println("sender is", e.From)
}A type switch branches on the dynamic type. Inside each case the variable has that type:
func describe(v any) string { // any is an alias for interface{}
switch x := v.(type) {
case int:
return fmt.Sprintf("int %d", x)
case string:
return fmt.Sprintf("string of length %d", len(x))
case fmt.Stringer:
return "stringer: " + x.String()
default:
return fmt.Sprintf("something else: %T", x)
}
}Generics: types as parameters
Interfaces abstract over behaviour. Sometimes you want to abstract over types while keeping full type safety, for example a function that works on a slice of anything. That is what type parameters are for:
func Filter[T any](items []T, keep func(T) bool) []T {
var out []T
for _, it := range items {
if keep(it) {
out = append(out, it)
}
}
return out
}
evens := Filter([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 }) // [2 4]T is inferred from the arguments. The constraint after the parameter says what T must support. any allows every type but lets you do little with it. comparable allows ==, which map keys need. cmp.Ordered allows < and >:
func MaxOf[T cmp.Ordered](first T, rest ...T) T {
m := first
for _, v := range rest {
if v > m {
m = v
}
}
return m
}
fmt.Println(MaxOf(3, 9, 4), MaxOf("pear", "apple")) // 9 pearTypes can be generic too:
type Stack[T any] struct{ items []T }
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}Practice
Build a tiny inventory model:
- Define
ProductwithSKU string,Name string,Qty int, and aString()method that prints likeKB-01 Keyboard (x3). - Define
Inventoryholding amap[string]*Product, withNewInventory(),Add(p *Product)andRemove(sku string, n int) bool(returnsfalseif the SKU is unknown or stock is insufficient). - Write a generic
Filterand use it to print every product withQty < 5, in SKU order.
Show solution
package main
import (
"fmt"
"maps"
"slices"
)
type Product struct {
SKU string
Name string
Qty int
}
func (p *Product) String() string { return fmt.Sprintf("%s %s (x%d)", p.SKU, p.Name, p.Qty) }
type Inventory struct{ items map[string]*Product }
func NewInventory() *Inventory { return &Inventory{items: make(map[string]*Product)} }
func (inv *Inventory) Add(p *Product) { inv.items[p.SKU] = p }
func (inv *Inventory) Remove(sku string, n int) bool {
p, ok := inv.items[sku]
if !ok || p.Qty < n {
return false
}
p.Qty -= n
return true
}
func Filter[T any](items []T, keep func(T) bool) []T {
var out []T
for _, it := range items {
if keep(it) {
out = append(out, it)
}
}
return out
}
func main() {
inv := NewInventory()
inv.Add(&Product{SKU: "KB-01", Name: "Keyboard", Qty: 8})
inv.Add(&Product{SKU: "MS-02", Name: "Mouse", Qty: 2})
inv.Add(&Product{SKU: "MN-03", Name: "Monitor", Qty: 4})
fmt.Println(inv.Remove("KB-01", 5), inv.Remove("MS-02", 9)) // true false
var all []*Product
for _, sku := range slices.Sorted(maps.Keys(inv.items)) {
all = append(all, inv.items[sku])
}
for _, p := range Filter(all, func(p *Product) bool { return p.Qty < 5 }) {
fmt.Println(p) // fmt calls String() because *Product is a fmt.Stringer
}
}true false
KB-01 Keyboard (x3)
MN-03 Monitor (x4)
MS-02 Mouse (x2)The map stores pointers, so Remove changes the product in place. String has a pointer receiver, which is why the slice holds *Product: a plain Product value would not be a fmt.Stringer.
Key takeaways
- Structs group named fields and are copied on assignment; use pointers (
*T,&v) to share and mutate one value. - Use pointer receivers when a method mutates state or the struct is large, and keep receivers consistent per type.
- Embedding promotes fields and methods for composition; it does not create an "is-a" relationship.
- Interfaces are satisfied implicitly: keep them small, define them where they are used, and return concrete types.
- An interface holding a nil pointer is not nil; return a literal
nilinstead. - Generics with constraints (
any,comparable,cmp.Ordered) remove duplication across types without losing type safety.
Check yourself
Quiz
0/4 correct · 4 left
1.Square has func (s *Square) Area() float64. Why does var sh Shape = Square{S: 2} fail to compile, where Shape requires Area() float64?
2.Post embeds Timestamps, which has a Touch() method. What does that give you?
3.What does this print? var p *EmailNotifier; var n Notifier = p; fmt.Println(n == nil)
4.You want a generic function Max[T ...](a, b T) T that uses the > operator. Which constraint fits?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.