Step 03 of 24

Structs, methods & interfaces

Model data with structs, attach behaviour with methods, decouple code with interfaces and write reusable generic functions.

17 min 8 sections

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:

go
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.

go
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:

go
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 needed

Field 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:

go
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:

go
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:

go
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:

go
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.

go
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. NewCounter returns *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:

go
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 receiver

The 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:

go
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:

go
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:

go
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 >:

go
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 pear

Types can be generic too:

go
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:

  1. Define Product with SKU string, Name string, Qty int, and a String() method that prints like KB-01 Keyboard (x3).
  2. Define Inventory holding a map[string]*Product, with NewInventory(), Add(p *Product) and Remove(sku string, n int) bool (returns false if the SKU is unknown or stock is insufficient).
  3. Write a generic Filter and use it to print every product with Qty < 5, in SKU order.
Show solution
go
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
	}
}
text
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 nil instead.
  • Generics with constraints (any, comparable, cmp.Ordered) remove duplication across types without losing type safety.

Check yourself

Quiz

0/4 correct · 4 left

  1. 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. 2.Post embeds Timestamps, which has a Touch() method. What does that give you?

  3. 3.What does this print? var p *EmailNotifier; var n Notifier = p; fmt.Println(n == nil)

  4. 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.