Lesson 4 • Intermediate
Structs and Interfaces 🐹
By the end of this lesson you'll model real-world things with your own struct types, give them behaviour with methods, change them safely with pointers, build bigger types by composition, and see how a type quietly satisfies an interface — the heart of idiomatic Go.
What You'll Learn in This Lesson
- Define your own struct types with named fields
- Create instances using field names and positional values
- Attach methods, and choose value vs pointer receivers
- Use pointers with & (address) and * (dereference)
- Build bigger types by embedding (composition, not inheritance)
- Understand how a type satisfies an interface automatically
func and calling it. Every example below is genuine Go: paste it into the free Go Playground (no install needed), or run it locally with Go from go.dev. The expected output is shown under every block so you can self-check.1️⃣ Defining a Struct and Making Instances
A struct (short for "structure") is a type you define to group related values together — like a Person with a Name and an Age. You declare the shape once with type Person struct { ... }, then create as many instances (individual values) as you like. You can fill the fields by name (clearest and safest) or by position (you must list them in the exact field order). Any field you leave out gets its type's zero value — 0 for numbers, "" for strings, false for bools.
package main
import "fmt"
// A struct is a named type that groups related fields together.
// Think of it as a blueprint for one "thing" in your program.
type Person struct {
Name string
Age int
}
func main() {
// 1) Field names (clearest, order-independent, safest):
alice := Person{Name: "Alice", Age: 30}
// 2) Positional (must match the field order exactly):
bob := Person{"Bob", 25}
// 3) Zero value — every field gets its type's default:
var empty Person // Name: "" (empty string), Age: 0
// Read and write fields with a dot.
alice.Age = 31 // it was her birthday
fmt.Println("alice:", alice.Name, alice.Age) // alice: Alice 31
fmt.Println("bob: ", bob.Name, bob.Age) // bob: Bob 25
fmt.Printf("empty: %+v\n", empty) // %+v prints field names
}alice: Alice 31
bob: Bob 25
empty: {Name: Age:0}Your turn. Define a small struct and build one instance using field names. Fill in the blanks marked ___ using the // 👉 hints, then run it and check the output.
package main
import "fmt"
func main() {
// 🎯 YOUR TURN — fill in each ___ then run it.
// 1) Define a Book struct with a Title (string) and Pages (int).
type Book struct {
___ // 👉 Title string
___ // 👉 Pages int
}
// 2) Make a book using FIELD NAMES.
b := Book{___} // 👉 Title: "Go 101", Pages: 200
// 3) Print the title and page count (this line already works).
fmt.Printf("%s has %d pages\n", b.Title, b.Pages)
// ✅ Expected output:
// Go 101 has 200 pages
}Go 101 has 200 pages___ blanks, then run it in the Go Playground and confirm your output matches.2️⃣ Methods: Value vs Pointer Receivers
A method is a function attached to a type. You write it with a receiver in parentheses before the name: func (c Counter) Show(). The receiver type decides whether the method sees the original or a copy. A value receiver (c Counter) gets a copy — perfect for read-only methods, but any change is thrown away. A pointer receiver (c *Counter) works on the original, so changes stick. This value-vs-pointer choice is the single most common Go gotcha, so study both halves of this example.
package main
import "fmt"
type Counter struct {
Count int
}
// VALUE receiver (c Counter): works on a COPY.
// Changes here do NOT affect the original.
func (c Counter) Show() {
fmt.Println("count is", c.Count)
}
// POINTER receiver (c *Counter): works on the ORIGINAL.
// Changes here DO stick.
func (c *Counter) Increment() {
c.Count++ // Go auto-dereferences, so it's c.Count, not (*c).Count
}
func main() {
c := Counter{Count: 0}
c.Increment() // pointer receiver -> modifies c
c.Increment() // count is now 2
c.Show() // value receiver -> reads (a copy of) c
// Rule of thumb: use a POINTER receiver when the method must
// change the struct, OR the struct is large (avoid copying).
}count is 23️⃣ Pointers: & and *
A pointer holds the memory address of a value rather than the value itself. Two operators do all the work: &x takes the address of x (giving a pointer), and *p dereferences the pointer (giving back the value, which you can read or overwrite). Passing a pointer into a function lets that function change your original value. Go is friendly here: it auto-dereferences struct pointers (write u.Name, not (*u).Name) and forbids pointer arithmetic, so you can't accidentally walk off into invalid memory.
package main
import "fmt"
type User struct {
Name string
}
// Taking a *User lets the function modify the caller's struct.
func rename(u *User, name string) {
u.Name = name // Go auto-dereferences struct pointers
}
func main() {
x := 42
p := &x // & takes the ADDRESS of x; p is a *int
fmt.Println(*p) // * DEREFERENCES: read the value at p -> 42
*p = 100 // write through the pointer -> changes x
fmt.Println(x) // 100 — x changed!
user := User{Name: "Alice"}
rename(&user, "Bob") // pass the address so the change sticks
fmt.Println("user:", user.Name) // user: Bob
// Go has no pointer arithmetic (no p++ / p + 4) — memory stays safe.
}42
100
user: BobYour turn — fix a wallet that won't update. The Deposit method currently can't change the balance. Give it a pointer receiver and pass the right amount.
package main
import "fmt"
type Wallet struct {
Balance int
}
// 🎯 YOUR TURN — make Deposit actually change the wallet.
// 1) Give Deposit a POINTER receiver so the change sticks.
func (w ___) Deposit(amount int) { // 👉 *Wallet
w.Balance += amount
}
func main() {
wallet := Wallet{Balance: 100}
// 2) Add 50 to the wallet.
wallet.Deposit(___) // 👉 50
fmt.Println("balance:", wallet.Balance)
// ✅ Expected output:
// balance: 150
// (With a VALUE receiver you'd wrongly get 100 — try it to see why!)
}balance: 150___ blanks, run it in the Go Playground, then change *Wallet back to Wallet to see the balance wrongly stay at 100.4️⃣ Embedding: Composition over Inheritance
Go has no classes and no inheritance. Instead you compose bigger types out of smaller ones by embedding: list a type inside a struct with no field name. The embedded type's fields and methods are then promoted — you can reach them directly on the outer struct. So an Employee that embeds User can use e.Name instead of e.User.Name, and inherits any methods User defines. This is how Go reuses code: build from parts rather than extending a base class.
package main
import "fmt"
type Address struct {
City string
}
// A method on the embedded type comes along for free.
func (a Address) Where() string { return "lives in " + a.City }
type User struct {
Name string
}
// Employee EMBEDS User and Address (no field name = embedded).
// Go has no inheritance — this is composition.
type Employee struct {
User // promotes User's fields/methods
Address // promotes Address's fields/methods
Salary int
}
func main() {
e := Employee{
User: User{Name: "Alice"},
Address: Address{City: "London"},
Salary: 50000,
}
// Promoted: reach Name and City directly, no e.User.Name needed.
fmt.Println(e.Name) // Alice (promoted from User)
fmt.Println(e.City) // London (promoted from Address)
fmt.Println(e.Where()) // lives in London (promoted method)
fmt.Println(e.Salary) // 50000
}Alice
London
lives in London
500005️⃣ A First Look at Interfaces
An interface is a named list of method signatures — a contract that says "any type with these methods qualifies." The big idea in Go: a type satisfies an interface automatically, just by having the right methods. There is no implements keyword and nothing to declare. Below, both Dog and Robot have a Speak() string method, so both are Speakers — and one function can accept either. You'll go much deeper into interfaces in a later lesson; for now, just notice how loose and flexible this makes your code.
package main
import "fmt"
// An interface is a list of method signatures — a contract.
// A type satisfies it just by HAVING those methods. There is no
// "implements" keyword; satisfaction is automatic (structural typing).
type Speaker interface {
Speak() string
}
type Dog struct{ Name string }
type Robot struct{ ID int }
// Dog has a Speak() string method -> Dog satisfies Speaker.
func (d Dog) Speak() string { return d.Name + " says Woof" }
// Robot also has Speak() string -> Robot satisfies Speaker too.
func (r Robot) Speak() string { return fmt.Sprintf("Unit %d: Beep", r.ID) }
// announce accepts ANY Speaker — it doesn't care about the concrete type.
func announce(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
announce(Dog{Name: "Rex"})
announce(Robot{ID: 7})
}Rex says Woof
Unit 7: BeepCommon Errors (and the fix)
- My method ran but the struct didn't change. You used a value receiver, so the method edited a copy. Switch to a pointer receiver:
func (w *Wallet) Deposit(...). This is the #1 struct gotcha. - "invalid operation: a == b (struct ... cannot be compared)". Structs are comparable with
==only if every field is comparable. A struct containing a slice, map, or function can't be compared — compare the individual fields you care about instead. - "panic: runtime error: invalid memory address or nil pointer dereference". You dereferenced a pointer that's
nil(e.g.var u *User; u.Name). Create the value first (u := &User{}) or guard withif u != nil { ... }. - "too few values in Person{...}". Positional literals must list every field in order. Either supply them all, or — better — switch to field names:
Person{Name: "Al"}. - "cannot use Robot ... as Speaker value: missing method Speak". The type doesn't have the exact method the interface needs. Check the method name, the receiver, and that the signature (parameters and return types) match precisely.
Pro Tips
- 💡 Prefer field names in struct literals —
User{Name: "Al"}survives reordering and adding fields; positional literals break. - 💡 Be consistent with receivers: if one method needs a pointer receiver, give them all pointer receivers so callers don't have to think about it.
- 💡 Keep interfaces small. "The bigger the interface, the weaker the abstraction." — Rob Pike. One or two methods is ideal.
- 💡 Print with
%+v(fmt.Printf("%+v", x)) to see a struct's field names and values while debugging.
📋 Quick Reference
| Task | Go Syntax |
|---|---|
| Define a struct | type User struct { Name string } |
| Instance by name | u := User{Name: "Al"} |
| Instance positional | u := User{"Al"} |
| Value receiver (reads) | func (u User) Hi() {} |
| Pointer receiver (writes) | func (u *User) Set() {} |
| Address / dereference | p := &x; *p = 10 |
| Embedding | type Admin struct { User } |
| Interface | type Speaker interface { Speak() string } |
Frequently Asked Questions
Q: When should I use a pointer receiver instead of a value receiver?
Use a pointer receiver (e.g. func (c *Counter)) when the method needs to change the struct, or when the struct is large and you want to avoid copying it. Use a value receiver when the method only reads the data. A common convention is to be consistent: if any method needs a pointer receiver, give them all pointer receivers.
Q: Why didn't my method change the struct?
You almost certainly used a value receiver. A value receiver gets a COPY of the struct, so any changes are thrown away when the method returns. Switch to a pointer receiver — func (w *Wallet) Deposit(...) — and the change will stick.
Q: What is the difference between & and *?
& takes the address of a value, producing a pointer (p := &x makes p a *int). * does the opposite: it dereferences a pointer to reach the value it points at (*p reads or writes x). For struct fields Go auto-dereferences, so you can write u.Name even when u is a *User.
Q: Does Go have classes or inheritance?
No. Go has structs and interfaces but no classes and no inheritance. Instead you compose behaviour by embedding one struct inside another, which promotes the inner type's fields and methods. The Go motto is 'composition over inheritance'.
Q: How does a type satisfy an interface in Go?
Automatically — there is no 'implements' keyword. If a type has every method listed in the interface (matching name, parameters, and return types), it satisfies that interface. This is called structural typing, and it lets unrelated types share an interface without any explicit declaration.
Mini-Challenge: Rectangle Area
No blanks this time — just a brief and an outline. Define the struct and its method yourself, then run it and check your output against the expected line. This is exactly the shape of code real Go programs are built from.
package main
import "fmt"
func main() {
// 🎯 MINI-CHALLENGE: a Rectangle with a method
//
// 1. Define a Rectangle struct with Width and Height (both int).
// 2. Add a method Area() int that returns Width * Height.
// (A value receiver is fine — Area only reads the struct.)
// 3. In main, make a Rectangle{Width: 4, Height: 5}.
// 4. Print: "area is 20"
//
// ✅ Expected output:
// area is 20
// your code here
}area is 20area is 20.🎉 Lesson Complete!
- ✅ A struct groups related fields; build instances by name or by position
- ✅ Omitted fields take their type's zero value (
0,"",false) - ✅ Value receivers read a copy; pointer receivers change the original
- ✅
&takes an address,*dereferences; Go has no pointer arithmetic - ✅ Embedding composes types and promotes fields/methods — no inheritance
- ✅ A type satisfies an interface automatically just by having its methods
- ✅ Next lesson: Concurrency with Goroutines — Go's superpower for doing many things at once
Sign up for free to track which lessons you've completed and get learning reminders.