Õppetunnile juurdepääsu kontrollimine...

Saving to Disk

Turn Tasks into JSON

Video ja lugemine31 minEelvaade

Vaatad tasuta eelvaate õppetundi.

Goal

By the end of this lesson, Tasker can turn all its tasks into JSON text and load tasks back from JSON. You add a json command to the shell that shows your tasks in JSON format. In the next lesson, you write this JSON to a file.

What you will learn

  • What JSON is, and why programs use it to store and exchange data
  • How to turn Go values into JSON with json.Marshal
  • How to turn JSON into Go values with json.Unmarshal
  • How struct tags control field names in JSON
  • What happens with unknown fields, missing fields, and wrong types
  • Why JSON ignores unexported fields
  • How to load data safely, so a bad input does not destroy good data

Why this matters

To save tasks to a file, you need a text format for them. JSON (JavaScript Object Notation) is the most common format for this. Web APIs use it, many configuration files use it, and every programming language can read it. In Part 2, your API sends and receives JSON in every request. The skills from this lesson carry over directly.

JSON looks like this:

{"id": 1, "title": "Buy milk", "done": false, "tags": ["shop"]}

It has objects ({...}) with named fields, arrays ([...]), strings, numbers, true, false, and null.

Step 1: Add struct tags to Task

Open task/task.go and change the Task struct:

type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	Tags      []string  `json:"tags,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

The text in backticks after each field is a struct tag. A tag is extra information that packages can read. Here, the encoding/json package reads the json tags:

  • json:"id" says: in JSON, call this field id. Without a tag, JSON uses the Go field name, ID. JSON usually uses lowercase names, so tags let both sides follow their own conventions.
  • json:"created_at" uses snake case, a common style in JSON APIs.
  • omitempty says: leave this field out when it is empty. A task without tags then has no tags field at all.

Run go fmt ./... after you add the tags. It lines them up neatly.

Step 2: Encode a task to JSON

Replace play/main.go:

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"tasker/task"
)

func main() {
	t := task.Task{
		ID:        1,
		Title:     "Buy milk",
		Tags:      []string{"shop"},
		CreatedAt: time.Date(2026, 10, 16, 9, 30, 0, 0, time.UTC),
	}

	data, err := json.Marshal(t)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(data))
}

Run go run ./play. Output:

{"id":1,"title":"Buy milk","done":false,"tags":["shop"],"created_at":"2026-10-16T09:30:00Z"}
  • json.Marshal turns a Go value into JSON. "Marshal" is an old word for "arrange in order". In Go, it means converting a value into a format that can be stored or sent.
  • It returns []byte, a slice of bytes, not a string. Files and network connections work with bytes, so this is the most useful form. string(data) converts the bytes to a string for printing.
  • It can fail, so it returns an error. For simple structs it almost never fails, but you still check.
  • time.Time becomes a standard date-time text, "2026-10-16T09:30:00Z". This format is called RFC 3339. The Z means UTC time.

Step 3: Decode JSON into a task

Add this to the end of main:

	input := `{"id": 7, "title": "Call Ana", "done": true, "color": "red"}`
	var loaded task.Task
	if err := json.Unmarshal([]byte(input), &loaded); err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(loaded, loaded.CreatedAt.IsZero())

Output:

7. [x] Call Ana true
  • json.Unmarshal reads JSON and fills a Go value.
  • You pass &loaded, a pointer. Unmarshal must change your variable, and you learned in Lesson 3.1 that a function needs a pointer for that.
  • The input has a field color that Task does not have. Unmarshal ignores unknown fields.
  • The input has no created_at. Missing fields keep their zero value, so CreatedAt.IsZero() is true.
  • The backticks around input create a raw string. Inside it, you can write " without escaping it. Raw strings are useful for JSON in code.

Step 4: See the errors

Add these lines:

	err = json.Unmarshal([]byte(`{"id": "seven"}`), &loaded)
	fmt.Println("error:", err)

	err = json.Unmarshal([]byte(`{"id": 7,`), &loaded)
	fmt.Println("error:", err)

Output:

error: json: cannot unmarshal string into Go struct field Task.id of type int
error: unexpected end of JSON input

The first error is a type mismatch: the JSON has a string where Go expects a number. The second error is broken JSON: the text ends too early. In both cases, you get a clear error instead of wrong data. Go's static types protect you again.

Step 5: JSON ignores unexported fields

Add these lines:

	store := task.NewStore()
	data, _ = json.Marshal(store)
	fmt.Println(string(data))

Output:

{}

The store has data, but the JSON is empty. encoding/json lives in a different package, so it follows the same rule as any other package: it can only see exported fields. tasks and nextID are unexported, so JSON skips them.

This is a good thing. It means the store decides itself what it saves. In the next step, you give it methods for exactly that.

Step 6: Add JSON methods to the store

Open task/store.go. Add "encoding/json" and "fmt" to the imports, then add two methods at the end:

// ToJSON returns all tasks as indented JSON.
func (s *Store) ToJSON() ([]byte, error) {
	return json.MarshalIndent(s.List(), "", "  ")
}

// LoadJSON replaces all tasks in the store with the tasks in data.
func (s *Store) LoadJSON(data []byte) error {
	var list []Task
	if err := json.Unmarshal(data, &list); err != nil {
		return err
	}

	tasks := make(map[int]Task)
	highest := 0
	for _, t := range list {
		if err := ValidateTitle(t.Title); err != nil {
			return fmt.Errorf("task %d: %v", t.ID, err)
		}
		tasks[t.ID] = t
		highest = max(highest, t.ID)
	}

	s.tasks = tasks
	s.nextID = highest + 1
	return nil
}

ToJSON saves the sorted list of tasks, not the map. A JSON array of tasks is simple to read and to edit by hand. json.MarshalIndent works like Marshal, but it adds new lines and indentation. The second argument is a prefix for each line, here empty. The third is the indentation, here two spaces.

LoadJSON does more work, because data from outside can be wrong:

  • It decodes into a new list, not into the store.
  • It checks every task with ValidateTitle. A file edited by hand could contain an empty title.
  • It builds a new map and only replaces s.tasks at the end, when everything is valid. If any step fails, the store keeps its old tasks. A bad file never leaves the store half loaded.
  • It sets nextID to one more than the highest ID, so new tasks never reuse an ID. max is a built-in function that returns the larger value.

Step 7: Try the store methods

Add this to play/main.go:

	err = store.LoadJSON([]byte(`[{"id": 3, "title": "Plan trip"}, {"id": 8, "title": "Pay rent", "done": true}]`))
	fmt.Println(err)
	store.Add("New task", nil)
	for _, t := range store.List() {
		fmt.Println(t)
	}

	fmt.Println(store.LoadJSON([]byte(`[{"id": 1, "title": ""}]`)))

Output:

<nil>
3. [ ] Plan trip
8. [x] Pay rent
9. [ ] New task
task 1: title is empty

The new task gets ID 9, one more than the highest loaded ID. The last load fails, and the store still holds its three tasks.

Step 8: Add a json command to the shell

In shell.go, add a new case before quit:

		case "json":
			data, err := store.ToJSON()
			if err != nil {
				fmt.Println("error:", err)
				continue
			}
			fmt.Println(string(data))

Add json to the list of commands in the welcome message. Then build and try it:

> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add Read book
Added: 2. [ ] Read book
> done 2
Completed: 2. [x] Read book
> json
[
  {
    "id": 1,
    "title": "Buy milk",
    "done": false,
    "tags": [
      "shop"
    ],
    "created_at": "2026-10-16T21:52:17.069072747Z"
  },
  {
    "id": 2,
    "title": "Read book",
    "done": true,
    "created_at": "2026-10-16T21:52:17.069136303Z"
  }
]

Your times are different. Notice that "Read book" has no tags field, because of omitempty.

Why it works: types describe the data on both sides

The struct is the contract. Go reads the struct fields and their tags to decide how JSON should look. You never write code that builds JSON text by hand, and you never read JSON text character by character. When the data does not match the struct, you get an error.

In Part 2, you use the same struct tags to define the JSON that your API sends and receives. Fiber uses encoding/json behind the scenes.

Exercises

  1. Rename a field. Change the tag on Done to json:"completed". Run the json command. What happens when you load JSON that still uses "done"? Change it back.
  2. Hide a field. The tag json:"-" tells JSON to always skip a field. Add a field Note string with this tag. Show that it never appears in JSON.
  3. Load from the shell. Add a load command that reads one line of JSON after the command, for example load [{"id": 5, "title": "Test"}], and passes it to store.LoadJSON. Hint: use strings.TrimPrefix(line, "load ") on the full line.
  4. Duplicate IDs. What does LoadJSON do with [{"id": 1, "title": "A"}, {"id": 1, "title": "B"}]? Change it to return an error for a duplicate ID.
  5. Pretty or compact. Print the same list with json.Marshal and with json.MarshalIndent. When would you choose each one?

Checkpoint

You are ready for the next lesson if you can:

  • Turn a struct into JSON with json.Marshal and back with json.Unmarshal
  • Explain why Unmarshal needs a pointer
  • Use struct tags to rename a field and to leave out empty values
  • Explain what happens with unknown fields, missing fields, and wrong types
  • Explain why LoadJSON builds a new map before it replaces the old one