Tarkistetaan oppitunnin käyttöoikeutta...

Modeling Data

Handle Errors the Go Way

Video ja luettava38 minEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, the rules of Tasker live in the task package, not in the shell. Functions such as Add and Complete return errors, and the shell decides how to show them. You create your own error values and check for them with errors.Is.

What you will learn

  • Why Go treats errors as normal values, not exceptions
  • How to create errors with errors.New and fmt.Errorf
  • Sentinel errors: named error values that callers can check for
  • How to check for a specific error with errors.Is
  • The if err != nil pattern and early returns
  • Why a function can change a map you pass to it, but not a struct
  • When to use panic, and when not to

Why this matters

Right now, the shell does everything. It reads input, checks titles, finds tasks, changes them, and prints messages. That works for one shell, but in Part 2 the same rules must work for an HTTP API. An API cannot print error: ... to a terminal. It must return a status code and a JSON response.

The solution is to separate two jobs:

  • The task package decides if something is wrong and returns an error.
  • The caller (the shell, the CLI, and later the API) decides how to show that error.

Step 1: Errors are values

In many languages, a function "throws" an exception, and the error jumps up through the program until something catches it. Go does not work this way. In Go, an error is a normal return value, and the caller handles it right away:

id, err := strconv.Atoi(text)
if err != nil {
	// handle the problem here
}

error is a built-in type. A function that can fail returns an error as its last result:

  • nil means "no error, everything worked."
  • Any other value describes what went wrong.

You can see every place where an error can happen, because each one is written in the code. This makes Go code longer, but also easier to follow. When you read a function, you know which lines can fail and what happens when they do.

Step 2: Define sentinel errors

Create task/errors.go:

package task

import "errors"

// Errors that task functions can return.
var (
	ErrEmptyTitle   = errors.New("title is empty")
	ErrTitleTooLong = errors.New("title is too long")
	ErrNotFound     = errors.New("task not found")
	ErrAlreadyDone  = errors.New("task is already done")
)

errors.New creates an error with a fixed message. Each variable here is a sentinel error: a named value that callers can compare against. The var ( ... ) block declares several variables at once.

Go has naming conventions for errors:

  • Error variables start with Err, for example ErrNotFound.
  • Error messages start with a small letter and do not end with a period, because they are often combined with other text.

Step 3: Move the rules into the task package

First, open task/task.go and delete the old Complete function from Lesson 2.1. A new, better Complete replaces it.

Create task/actions.go:

package task

import (
	"fmt"
	"strconv"
)

// ValidateTitle checks that a title is not empty and not too long.
func ValidateTitle(title string) error {
	if title == "" {
		return ErrEmptyTitle
	}
	if len(title) > MaxTitleLength {
		return ErrTitleTooLong
	}
	return nil
}

// ParseID turns text into a task ID. A valid ID is a whole number greater than 0.
func ParseID(text string) (int, error) {
	id, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("%q is not a number", text)
	}
	if id < 1 {
		return 0, fmt.Errorf("task ID must be greater than 0, got %d", id)
	}
	return id, nil
}

// Add validates the title, creates a task, and stores it in tasks.
func Add(tasks map[int]Task, id int, title string, tags []string) (Task, error) {
	if err := ValidateTitle(title); err != nil {
		return Task{}, err
	}
	t := New(id, title, tags)
	tasks[id] = t
	return t, nil
}

// Complete marks the task with the given ID as done.
func Complete(tasks map[int]Task, id int) (Task, error) {
	t, ok := tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	if t.Done {
		return t, ErrAlreadyDone
	}
	t.Done = true
	tasks[id] = t
	return t, nil
}

Look at the patterns in this code.

Return early. Each function checks for problems first and returns as soon as it finds one. The successful path continues down the left side of the function, without deep nesting. This is the normal shape of Go code.

Return the zero value with an error. When Add fails, it returns Task{}, an empty task, together with the error. The rule for callers is simple: if err is not nil, ignore the other results.

if err := ...; err != nil is a short form. It creates err and checks it in one line. The variable err exists only inside the if.

fmt.Errorf creates an error with a formatted message, like fmt.Sprintf. Use it when the message must include details, such as the text the user typed.

Step 4: Functions can change maps

Look at Add again. It stores a task with tasks[id] = t, and it does not return the map. Yet the caller sees the new task.

In Lesson 2.1, you learned that Go copies a struct when you pass it to a function. A map is different. A map value is a small handle that points to the real data. When you pass a map to a function, Go copies the handle, but both handles point to the same data. So changes inside the function are visible outside.

You passThe function getsChanges visible to the caller?
a structa full copyNo
a mapa copy of the handle, with the same dataYes
a slicea copy of the view, with the same itemsYes, for changed items. No, for append

In Module 3, you learn pointers, which give you the same control over structs.

Step 5: Update the shell

Open shell.go. Add "errors" to the imports and remove "strconv", which is no longer needed there. Replace the add case:

		case "add":
			title, tags := task.SplitTags(words[1:])
			t, err := task.Add(tasks, nextID, title, tags)
			if err != nil {
				fmt.Println("error:", err)
				continue
			}
			nextID++
			fmt.Println("Added:", task.Format(t))

Replace the done case:

		case "done":
			if len(words) != 2 {
				fmt.Println("error: done needs exactly one task ID")
				continue
			}
			id, err := task.ParseID(words[1])
			if err != nil {
				fmt.Println("error:", err)
				continue
			}
			t, err := task.Complete(tasks, id)
			if errors.Is(err, task.ErrNotFound) {
				fmt.Printf("error: no task with ID %d (type \"list\" to see all tasks)\n", id)
				continue
			}
			if err != nil {
				fmt.Println("error:", err)
				continue
			}
			fmt.Println("Completed:", task.Format(t))

The shell no longer contains any task rules. It only reads input, calls the task package, and prints results.

errors.Is(err, task.ErrNotFound) checks if err is a specific sentinel error. Here the shell uses it to give a more helpful message for one kind of error. All other errors get the default message. In Module 3, you learn how to wrap errors with extra context, and errors.Is still finds the original error inside. So always use errors.Is instead of err == task.ErrNotFound.

Step 6: Simplify the command-line mode

The one-shot done command in main.go cannot do anything useful until Tasker saves tasks to a file. Remove the done case and the runDone function. Then update runAdd so it uses the same rules as the shell:

func runAdd(args []string) {
	title, tags := task.SplitTags(args)
	if err := task.ValidateTitle(title); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	t := task.New(1, title, tags)
	fmt.Println("Added:", task.Format(t))
}

Remove "strconv" and "strings" from the imports in main.go if the compiler says they are unused. Also update the help text: remove the done line, and change the add line to Add a new task (saving comes in Module 3).

Now the shell and the command-line mode check titles the same way, because they both call task.ValidateTitle.

Step 7: Try it

go build
./tasker shell

A sample session:

> add
error: title is empty
> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add This title is much longer than fifty characters in total
error: title is too long
> done abc
error: "abc" is not a number
> done 0
error: task ID must be greater than 0, got 0
> done 7
error: no task with ID 7 (type "list" to see all tasks)
> done 1
Completed: 1. [x] Buy milk #shop
> done 1
error: task is already done
> quit
Bye!

Also try the command-line mode:

./tasker add
echo $?
./tasker add Test '#x'

The first command prints error: title is empty and exits with code 1.

Quote your tags in the terminal. In most shells, # starts a comment, so ./tasker add Test #x sends only Test to your program. Write '#x' with quotes. Inside tasker shell, you do not need quotes, because your program reads the line itself.

Why it works: panic is for bugs, errors are for problems

Go also has panic, which stops the program with a crash message. You have already seen panics: reading past the end of a slice, and writing to a nil map.

Use the two tools for different things:

  • Return an error when something can go wrong in normal use: a user types a bad ID, a file is missing, or a network call fails. The caller can handle these problems.
  • panic means a bug in the program, a situation that should never happen if the code is correct.

In this course, you almost never call panic yourself. When a function can fail, it returns an error.

Exercises

  1. A new rule. Add ErrTooManyTags. Make Add return it when a task has more than 5 tags. Test it in the shell.
  2. Reopen a task. Write Reopen(tasks map[int]Task, id int) (Task, error). It returns ErrNotFound for a missing task and a new error ErrNotDone for a task that is still open. Add a reopen <id> command to the shell.
  3. Delete with errors. Write Delete(tasks map[int]Task, id int) error in the task package, and use it for a delete <id> command in the shell.
  4. A friendlier message. In the shell, use errors.Is to handle ErrTitleTooLong. Print a message that includes the maximum length, for example error: title is too long (maximum is 50 characters).
  5. Ignore an error on purpose. Change id, err := task.ParseID(words[1]) to id, _ := task.ParseID(words[1]). Run done abc. What happens, and why is ignoring errors dangerous? Put the check back.

Checkpoint

You have finished Module 2 if you can:

  • Explain the difference between errors in Go and exceptions in other languages
  • Create errors with errors.New and fmt.Errorf
  • Define sentinel errors and check for them with errors.Is
  • Write a function that returns early when it finds a problem
  • Explain why Add can change the caller's map, but a function cannot change the caller's struct
  • Say when to return an error and when a panic is acceptable