Saving to Disk
Pointers and Methods: Build a Task Store
Goal
By the end of this lesson, Tasker has a Store type that owns all tasks. The shell no longer manages a map and an ID counter. It asks the store to add, find, and complete tasks. You also give Task its own String method, so fmt.Println prints a task correctly by itself.
What you will learn
- What a pointer is, and how to use
&and* - How to let a function change the caller's struct with a pointer
- How to attach methods to your own types
- The difference between value receivers and pointer receivers
- How unexported fields protect the data inside a type
- What a
nilpointer is, and why it crashes when you use it
Why this matters
In Lesson 2.4, the shell still holds two pieces of state: the tasks map and the nextID counter. Both must stay in sync, and any code can change them by mistake. In this module, you save tasks to a file, which adds more state, such as the file path. It is time to put the data and the code that works with it together in one type.
Step 1: Pointers in the playground
Replace play/main.go with this:
package main
import "fmt"
type Counter struct {
Count int
}
func addOneCopy(c Counter) {
c.Count++
}
func addOnePointer(c *Counter) {
c.Count++
}
func main() {
x := 10
p := &x
fmt.Println(*p)
*p = 20
fmt.Println(x)
c := Counter{}
addOneCopy(c)
fmt.Println(c.Count)
addOnePointer(&c)
fmt.Println(c.Count)
}
Run it with go run ./play. Output:
10
20
0
1
A pointer holds the memory address of a value, not the value itself.
&xmeans "the address ofx". Soppoints tox.*intis the type "pointer to anint". The type ofpis*int.*pmeans "the value thatppoints to". Reading*pgives10. Writing*p = 20changesx, becauseppoints tox.
Now look at the two functions. addOneCopy gets a copy of the struct, so the change is lost. This is the copying rule from Lesson 2.1. addOnePointer gets a pointer to the original, so its change stays.
Inside addOnePointer, the code writes c.Count++, not (*c).Count++. When you use a pointer to a struct, Go follows the pointer for you automatically.
Step 2: Methods
A method is a function attached to a type. Add these to play/main.go, above main:
func (c Counter) Show() string {
return fmt.Sprintf("count is %d", c.Count)
}
func (c *Counter) Increment() {
c.Count++
}
Add this at the end of main:
c.Increment()
fmt.Println(c.Show())
Output:
count is 2
The part in parentheses before the method name is the receiver. It works like a parameter, but you call the method with a dot: c.Increment().
There are two kinds of receivers:
- Value receiver
(c Counter): the method gets a copy. Use it for methods that only read the value. - Pointer receiver
(c *Counter): the method gets a pointer to the original. Use it for methods that change the value.
You wrote c.Increment(), not (&c).Increment(). Go takes the address for you when a method needs a pointer.
A simple rule: if any method of a type needs a pointer receiver, give all its methods pointer receivers. Mixing them makes code harder to reason about. Small types that never change, like
Task, can use value receivers.
Step 3: nil pointers
Add this at the end of main:
var empty *Counter
fmt.Println(empty == nil)
empty.Increment()
Output:
true
panic: runtime error: invalid memory address or nil pointer dereference
The zero value of a pointer is nil. It points to nothing. When Increment tries to change c.Count, there is no counter to change, so the program crashes. Before you use a pointer that could be nil, make sure it points to a real value. A constructor such as NewStore in the next step solves this, because it always returns a pointer to a ready value.
Remove these three lines before you continue.
Step 4: Give Task a String method
Open task/task.go. Change the Format function into a method. Only the first two lines change:
// String returns one line that describes the task.
func (t Task) String() string {
status := "[ ]"
if t.Done {
status = "[x]"
}
line := fmt.Sprintf("%d. %s %s", t.ID, status, t.Title)
for _, tag := range t.Tags {
line += " #" + tag
}
return line
}
The name String is special. When you print a value with fmt.Println or %v, and its type has a String() string method, fmt calls that method. So now you can write:
fmt.Println("Added:", t)
and get Added: 1. [ ] Buy milk #shop. In Module 4, you learn the feature that makes this work: interfaces.
In main.go, change task.Format(t) to t.
Step 5: Create the Store type
Create task/store.go:
package task
import (
"maps"
"slices"
)
// Store keeps tasks in memory and gives each new task a unique ID.
type Store struct {
tasks map[int]Task
nextID int
}
// NewStore returns an empty store that is ready to use.
func NewStore() *Store {
return &Store{
tasks: make(map[int]Task),
nextID: 1,
}
}
// Add validates the title, creates a task, and stores it.
func (s *Store) Add(title string, tags []string) (Task, error) {
if err := ValidateTitle(title); err != nil {
return Task{}, err
}
t := New(s.nextID, title, tags)
s.tasks[t.ID] = t
s.nextID++
return t, nil
}
// Get returns the task with the given ID.
func (s *Store) Get(id int) (Task, error) {
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
// Complete marks the task with the given ID as done.
func (s *Store) Complete(id int) (Task, error) {
t, err := s.Get(id)
if err != nil {
return Task{}, err
}
if t.Done {
return t, ErrAlreadyDone
}
t.Done = true
s.tasks[id] = t
return t, nil
}
// List returns all tasks, sorted by ID.
func (s *Store) List() []Task {
var list []Task
for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
list = append(list, s.tasks[id])
}
return list
}
// Len returns the number of tasks in the store.
func (s *Store) Len() int {
return len(s.tasks)
}
Look at the design:
- The fields are unexported.
tasksandnextIDstart with small letters, so code outside thetaskpackage cannot touch them. The shell cannot changenextIDby mistake or put a task in the map without validation. The only way in is through the methods. This is called encapsulation. NewStorereturns*Store.&Store{...}creates a store and returns its address. Every caller shares the same store, and the map is always created, so thenilmap panic from Lesson 2.3 cannot happen.- All methods use pointer receivers.
AddandCompletechange the store, so they need pointers.Get,List, andLenonly read, but they follow the rule from Step 2: one type, one kind of receiver. Completecallss.Get. Methods can call other methods. The "not found" rule now lives in one place.Listreturns a new slice. The caller gets its own copy of the tasks. Changes to that slice do not change the store.
Step 6: Clean up the old functions
The store replaces several functions from Module 2.
In task/actions.go, delete the Add and Complete functions. Keep ValidateTitle and ParseID.
In task/list.go, delete SortedIDs, and change CountByTag so it accepts a slice of tasks instead of a map:
// CountByTag returns how many tasks have each tag.
func CountByTag(tasks []Task) map[string]int {
counts := make(map[string]int)
for _, t := range tasks {
for _, tag := range t.Tags {
counts[tag]++
}
}
return counts
}
Remove the maps and slices imports from list.go. go build tells you if an import is unused.
Step 7: Use the store in the shell
Update shell.go. The start of runShell becomes shorter:
func runShell() {
store := task.NewStore()
scanner := bufio.NewScanner(os.Stdin)
Update the cases that use tasks:
case "add":
title, tags := task.SplitTags(words[1:])
t, err := store.Add(title, tags)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("Added:", t)
case "list":
filter := ""
if len(words) == 2 {
filter = strings.TrimPrefix(strings.ToLower(words[1]), "#")
}
shown := 0
for _, t := range store.List() {
if filter != "" && !slices.Contains(t.Tags, filter) {
continue
}
fmt.Println(t)
shown++
}
if shown == 0 {
fmt.Println("No tasks found.")
}
In the done case, change task.Complete(tasks, id) to store.Complete(id), and print t instead of task.Format(t). In the tags case, change task.CountByTag(tasks) to task.CountByTag(store.List()).
Build and test:
go build
./tasker shell
> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add Fix bike #home #shop
Added: 2. [ ] Fix bike #home #shop
> done 1
Completed: 1. [x] Buy milk #shop
> done 1
error: task is already done
> list
1. [x] Buy milk #shop
2. [ ] Fix bike #home #shop
> tags
#home: 1
#shop: 2
> quit
Bye!
Tasker works exactly as before. The difference is in the design: the shell only talks to the store, and the store protects its own data.
Why it works: types with behavior
Go has no classes. Instead, you combine three simple tools:
- a struct holds the data,
- methods define what the type can do,
- unexported fields hide the details.
This gives you most of what classes give you in other languages, with fewer rules. You will use this pattern for every important part of Tasker: the file storage in this module, the HTTP handlers in Part 2, and the database repository after that.
Exercises
- A
Deletemethod. Addfunc (s *Store) Delete(id int) errorto the store. It returnsErrNotFoundfor a missing task. Add adelete <id>command to the shell. - A
Reopenmethod. AddReopen(id int) (Task, error), which marks a done task as open again. Return a new errorErrNotDoneif the task is not done. - A value receiver. Add
func (t Task) HasTag(tag string) booltoTask. Use it in the shell instead ofslices.Contains. - Prove the copy. Call
store.List(), change the title of the first task in the returned slice, then callstore.List()again. Is the title in the store changed? Explain why. - Break it on purpose. Change
Addto use a value receiver:func (s Store) Add(...). Add two tasks in the shell. What IDs do they get? Explain the result, then change it back. Hint: the map is shared, butnextIDis a copy.
Checkpoint
You are ready for the next lesson if you can:
- Explain what
&x,*int, and*pmean - Write a function that changes the caller's struct with a pointer
- Write methods with value receivers and pointer receivers, and explain when to use each
- Explain how unexported fields protect the data in
Store - Explain why a
nilpointer crashes, and howNewStoreprevents it
