Exercises — Module 8: Error Handling¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just run code mentally — actually write or type your attempt.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If you're stuck after a genuine effort, use the hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 5–10 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 15–25 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 30–60 min | 3 pts |
| ⭐ Expert | Open-ended; more than one good answer | 60+ min | 5 pts |
Exercise 1: Sentinel Error and errors.Is [🟢 Easy] [1 pt]¶
Context¶
Sentinel errors are package-level error values used as well-known signals. Callers match against them using errors.Is, which traverses wrapped error chains. This exercise builds the core habit: define a sentinel, wrap it, and use errors.Is — not == — to match it.
Task¶
- Define two package-level sentinel errors:
ErrNotFoundandErrExpired. - Write a function
lookup(id int) errorthat returnsErrNotFoundforid < 0andErrExpiredforid == 0, wrapped withfmt.Errorf("lookup %d: %w", id, ...). Returnnilfor positive IDs. - Call
lookupwith-1,0, and1. For each result, print whether the error isErrNotFound,ErrExpired, or nil. - Show that
err == ErrNotFoundreturns false for a wrapped error, whileerrors.Is(err, ErrNotFound)returns true.
Requirements¶
- Both sentinel errors are declared at package level using
errors.New -
lookupwraps the sentinel with%w(not%v) - The caller uses
errors.Isfor all checks - One comparison using
==is demonstrated to show it fails on wrapped errors
Hints¶
Hint 1 (try without this first)
Sentinel error declaration: The `%w` verb in `fmt.Errorf` preserves the original error so `errors.Is` can find it.Hint 2 (only if Hint 1 wasn't enough)
To demonstrate the `==` failure:Solution¶
Show Solution (attempt first!)
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrExpired = errors.New("expired")
)
func lookup(id int) error {
switch {
case id < 0:
return fmt.Errorf("lookup %d: %w", id, ErrNotFound)
case id == 0:
return fmt.Errorf("lookup %d: %w", id, ErrExpired)
default:
return nil
}
}
func main() {
ids := []int{-1, 0, 1}
for _, id := range ids {
err := lookup(id)
switch {
case errors.Is(err, ErrNotFound):
fmt.Printf("id=%d: not found\n", id)
case errors.Is(err, ErrExpired):
fmt.Printf("id=%d: expired\n", id)
case err == nil:
fmt.Printf("id=%d: ok\n", id)
}
}
// Demonstrate == fails on wrapped errors
err := lookup(-1)
fmt.Println("\nDirect == comparison:", err == ErrNotFound) // false
fmt.Println("errors.Is comparison:", errors.Is(err, ErrNotFound)) // true
}
Exercise 2: Custom Error Type with errors.As [🟢 Easy] [1 pt]¶
Context¶
Custom error types let callers extract structured data from an error — not just a string. errors.As finds the first value of the target type anywhere in the error chain and assigns it to the caller's pointer.
Task¶
- Define a
ValidationErrorstruct with fieldsField stringandMessage string. - Implement the
errorinterface on*ValidationError. - Write a function
validateAge(age int) errorthat returns a*ValidationErrorwhenage < 0orage > 150, wrapped withfmt.Errorf("validateAge: %w", ...). Returnnilotherwise. - Call
validateAge(-5). Useerrors.Asto extract the*ValidationErrorand print itsFieldandMessage.
Requirements¶
-
ValidationErrorhasFieldandMessagestring fields -
Error() stringreturns a human-readable message including both fields -
validateAgewraps the*ValidationErrorwith%w - The caller uses
errors.As, not a type assertion
Hints¶
Hint 1
The `errors.As` call looks like: Note: `ve` is declared as `*ValidationError` (a pointer), and you pass `&ve` (pointer to pointer) to `errors.As`.Solution¶
Show Solution
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: field %q: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 {
return fmt.Errorf("validateAge: %w", &ValidationError{
Field: "age",
Message: fmt.Sprintf("must be non-negative, got %d", age),
})
}
if age > 150 {
return fmt.Errorf("validateAge: %w", &ValidationError{
Field: "age",
Message: fmt.Sprintf("unrealistically large: %d", age),
})
}
return nil
}
func main() {
err := validateAge(-5)
if err == nil {
fmt.Println("valid")
return
}
fmt.Println("Error:", err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Field: %s\n", ve.Field)
fmt.Printf("Message: %s\n", ve.Message)
}
}
Exercise 3: Building an Error Chain [🟡 Medium] [2 pts]¶
Context¶
A well-structured error chain reads like a stack trace in plain English. Each layer adds context about what it was trying to do, and errors.Is can still find the original cause. This exercise builds the habit of consistent wrapping.
Task¶
Build a three-layer function stack that returns a wrapped error chain:
1. readLine(r io.Reader) (string, error) — reads one line; if io.EOF is returned from bufio.Scanner, wrap it as fmt.Errorf("readLine: %w", io.EOF)
2. parseLine(line string) (int, error) — parses the line as an integer using strconv.Atoi; wraps any error as fmt.Errorf("parseLine %q: %w", line, err)
3. processFile(path string) ([]int, error) — opens a file, reads all lines via readLine, parses each via parseLine, collects results; wraps errors as fmt.Errorf("processFile %s: %w", path, err)
Call processFile with a path to a temp file containing "1\n2\nabc\n4\n". Verify:
- The returned error message contains all three function names
- errors.Is(err, strconv.ErrSyntax) returns true
Requirements¶
- Each function wraps errors with
%w, not%v - The error message chain includes at least two function names visible in the string
-
errors.Is(err, strconv.ErrSyntax)returns true on the returned error
Hints¶
Hint 1
`strconv.Atoi` returns a `*strconv.NumError` whose `Err` field is `strconv.ErrSyntax` for non-numeric input. `errors.Is` traverses into `*strconv.NumError.Err`, so `errors.Is(err, strconv.ErrSyntax)` will be true if you wrap with `%w` at every layer.Hint 2
For reading lines without pulling in a full file, use `bufio.NewScanner(f)` and `scanner.Scan()`. When `Scan()` returns false, call `scanner.Err()` — if it's nil, you've reached EOF cleanly.Solution¶
Show Solution
package main
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
)
func parseLine(line string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
return 0, fmt.Errorf("parseLine %q: %w", line, err)
}
return n, nil
}
func processFile(path string) ([]int, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("processFile %s: %w", path, err)
}
defer f.Close()
var results []int
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
n, err := parseLine(line)
if err != nil {
return nil, fmt.Errorf("processFile %s: %w", path, err)
}
results = append(results, n)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("processFile %s: scan: %w", path, err)
}
return results, nil
}
func main() {
// Write a temp file with a bad line
content := "1\n2\nabc\n4\n"
if err := os.WriteFile("/tmp/nums.txt", []byte(content), 0644); err != nil {
fmt.Println("setup error:", err)
return
}
_, err := processFile("/tmp/nums.txt")
if err != nil {
fmt.Println("Error:", err)
// Output: processFile /tmp/nums.txt: parseLine "abc": strconv.Atoi: parsing "abc": invalid syntax
fmt.Println("Is ErrSyntax?", errors.Is(err, strconv.ErrSyntax)) // true
}
_ = io.EOF // suppress unused import
}
Exercise 4: errors.Join for Validation [🟡 Medium] [2 pts]¶
Context¶
errors.Join (Go 1.20) allows collecting multiple errors and returning them as one. This is the standard pattern for validation functions that should report all problems at once rather than stopping at the first failure.
Task¶
Write a validateUser(name, email string, age int) error function that:
1. Checks that name is not empty; if empty, appends errors.New("name is required")
2. Checks that email contains "@"; if not, appends errors.New("email must contain @")
3. Checks that age is between 0 and 150 inclusive; if not, appends fmt.Errorf("age %d out of range [0, 150]", age)
4. Returns errors.Join(errs...) — which returns nil if errs is empty
Call validateUser("", "notanemail", 200) and verify that the returned error contains all three sub-errors. Also call with valid input and verify nil is returned.
Requirements¶
- All three validation checks run regardless of previous failures
-
errors.Joinis used to combine errors -
errors.Isworks on the joined result to find individual sub-errors
Hints¶
Hint 1
Collect errors in a `[]error` slice, append to it conditionally, then return `errors.Join(errs...)`. `errors.Join` returns nil if called with no arguments or all-nil arguments.Solution¶
Show Solution
package main
import (
"errors"
"fmt"
"strings"
)
var (
ErrNameRequired = errors.New("name is required")
ErrEmailInvalid = errors.New("email must contain @")
)
func validateUser(name, email string, age int) error {
var errs []error
if name == "" {
errs = append(errs, ErrNameRequired)
}
if !strings.Contains(email, "@") {
errs = append(errs, ErrEmailInvalid)
}
if age < 0 || age > 150 {
errs = append(errs, fmt.Errorf("age %d out of range [0, 150]", age))
}
return errors.Join(errs...) // nil if len(errs) == 0
}
func main() {
// Invalid: all three fail
err := validateUser("", "notanemail", 200)
if err != nil {
fmt.Println("Validation errors:")
fmt.Println(err) // prints all three, newline-separated
fmt.Println("Is ErrNameRequired?", errors.Is(err, ErrNameRequired)) // true
fmt.Println("Is ErrEmailInvalid?", errors.Is(err, ErrEmailInvalid)) // true
}
fmt.Println()
// Valid: all pass
err = validateUser("Alice", "alice@example.com", 30)
fmt.Println("Valid input error:", err) // <nil>
}
Exercise 5: Named Returns and Deferred Error Decoration [🟡 Medium] [2 pts]¶
Context¶
Named return values let a deferred function see and modify the error a function is about to return. This pattern provides a single location to add the function name as a prefix to every error, no matter how many return paths exist.
Task¶
Write a function parsePositive(s string) (n int, err error) that:
1. Uses a named err return
2. Defers an anonymous function that, if err != nil, wraps it: err = fmt.Errorf("parsePositive: %w", err)
3. Uses strconv.Atoi to parse s — return immediately if parsing fails
4. If the parsed value is zero or negative, sets err = fmt.Errorf("must be positive, got %d", n) and returns
Call parsePositive with "", "-3", and "42". Verify the error message prefix appears in all error cases and that "42" returns without error.
Requirements¶
- Named return values are used
- A single
deferhandles wrapping for all error paths - No return site repeats the
"parsePositive:"prefix
Hints¶
Hint 1
The deferred function captures `err` by reference (it's a named return). When the function is about to return, the defer runs and can modify `err` before it leaves the function:Solution¶
Show Solution
package main
import (
"fmt"
"strconv"
)
func parsePositive(s string) (n int, err error) {
// Single wrap point: every error returned by this function gets the prefix
defer func() {
if err != nil {
err = fmt.Errorf("parsePositive: %w", err)
}
}()
n, err = strconv.Atoi(s)
if err != nil {
return // bare return — named values used; defer wraps err
}
if n <= 0 {
err = fmt.Errorf("must be positive, got %d", n)
return
}
return n, nil
}
func main() {
for _, s := range []string{"", "-3", "42"} {
n, err := parsePositive(s)
if err != nil {
fmt.Printf("parsePositive(%q): %v\n", s, err)
} else {
fmt.Printf("parsePositive(%q) = %d\n", s, n)
}
}
}
Exercise 6: Panic and Recover at a Boundary [🔴 Hard] [3 pts]¶
Context¶
panic propagates until recover stops it in a deferred function. The correct usage is a thin boundary layer that catches panics from called code and converts them to errors, preventing the panic from crashing the whole program. This is essential in server handlers, plugin systems, and any code that invokes user-provided callbacks.
Task¶
Implement a TaskRunner that runs a slice of functions safely:
- Define
type Task func() error - Write
func RunAll(tasks []Task) (results []error)that runs each task in order. For each task: a. If the task returns an error, append it toresultsb. If the task panics, recover from the panic and appendfmt.Errorf("task panicked: %v", recovered)toresults— but continue running remaining tasks - Create three tasks: one that succeeds, one that returns an error, and one that panics
- Run all three and print the results slice
Requirements¶
- All three tasks run even after the panicking task
- The panic is converted to an
errorvalue, not re-panicked - The
recoveris in a deferred function (not called directly) - The non-panicking tasks' results are correct
Hints¶
Hint 1 (structural hint)
The recover must be inside the loop body, inside a helper function — you cannot reuse a single defer across loop iterations (each `defer` is scoped to the function, and a loop iteration is not a function): Then `RunAll` calls `runOne` for each task.Hint 2
`results` can hold nil for successful tasks. You might want to print a success indicator for nil entries in `results`.Solution¶
Show Solution
package main
import (
"errors"
"fmt"
)
// Task is a unit of work that may fail or panic
type Task func() error
// runOne runs a single task and recovers from any panic, converting it to an error
func runOne(t Task) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("task panicked: %v", r)
}
}()
return t()
}
// RunAll runs all tasks in order, continuing even if a task panics
func RunAll(tasks []Task) []error {
results := make([]error, len(tasks))
for i, t := range tasks {
results[i] = runOne(t)
}
return results
}
func main() {
tasks := []Task{
// Task 1: success
func() error {
fmt.Println("Task 1: running")
return nil
},
// Task 2: normal error
func() error {
return errors.New("task 2: something went wrong")
},
// Task 3: panic
func() error {
panic("catastrophic failure in task 3")
},
}
results := RunAll(tasks)
fmt.Println("\nResults:")
for i, err := range results {
if err == nil {
fmt.Printf(" Task %d: OK\n", i+1)
} else {
fmt.Printf(" Task %d: %v\n", i+1, err)
}
}
}
Exercise 7: Full Error Handling System [⭐ Expert] [5 pts]¶
Context¶
In production Go code, error handling combines all the tools in this module: custom error types, sentinel errors, wrapping, errors.Is/errors.As, and sometimes a recovery boundary. This exercise asks you to design a small but complete system.
Task¶
Implement a mini user-registration service with a full error-handling story:
- Define
ErrDuplicate(sentinel) andErrInvalid(sentinel) - Define
RegistrationErrorstruct with fieldsUsername string,Reason string, and an underlyingCause error; implementError() stringandUnwrap() error - Write
Register(username string) errorthat: - Returns a
RegistrationErrorwrappingErrInvalidifusernameis empty or shorter than 3 chars - Returns a
RegistrationErrorwrappingErrDuplicateifusername == "admin"(pretend it's taken) - Otherwise returns nil
- Write a caller that calls
Registerwith"","ab","admin", and"alice", and for each error: - Checks
errors.Is(err, ErrDuplicate)anderrors.Is(err, ErrInvalid)to branch handling - Uses
errors.As(err, &re)to print theRegistrationError.Reason
Requirements¶
-
RegistrationErrorimplements bothError() stringandUnwrap() error -
errors.Iscorrectly identifies the underlying sentinel through theRegistrationErrorwrapper -
errors.Ascorrectly extracts the*RegistrationErrorfrom the returned error - All four test cases produce the expected output
Hints¶
Hint 1 (Unwrap pattern)
For `errors.Is` to traverse through `RegistrationError`, it needs an `Unwrap()` method: This lets `errors.Is(err, ErrDuplicate)` walk from `*RegistrationError` → `e.Cause` → `ErrDuplicate`.Hint 2 (combined check)
You can check both `errors.Is` and `errors.As` on the same error:Solution¶
Show Solution
package main
import (
"errors"
"fmt"
)
// Sentinel errors for callers to match against
var (
ErrDuplicate = errors.New("username already taken")
ErrInvalid = errors.New("username invalid")
)
// RegistrationError carries structured details and wraps a sentinel cause
type RegistrationError struct {
Username string
Reason string
Cause error // the underlying sentinel
}
func (e *RegistrationError) Error() string {
return fmt.Sprintf("register %q: %s: %v", e.Username, e.Reason, e.Cause)
}
// Unwrap enables errors.Is and errors.As to traverse into Cause
func (e *RegistrationError) Unwrap() error { return e.Cause }
// Register attempts to register a new username
func Register(username string) error {
if len(username) < 3 {
return &RegistrationError{
Username: username,
Reason: "must be at least 3 characters",
Cause: ErrInvalid,
}
}
if username == "admin" {
return &RegistrationError{
Username: username,
Reason: "username is reserved",
Cause: ErrDuplicate,
}
}
return nil
}
func main() {
usernames := []string{"", "ab", "admin", "alice"}
for _, u := range usernames {
err := Register(u)
if err == nil {
fmt.Printf("Register(%q): OK\n", u)
continue
}
var re *RegistrationError
if errors.As(err, &re) {
fmt.Printf("Register(%q): reason=%q\n", u, re.Reason)
}
switch {
case errors.Is(err, ErrInvalid):
fmt.Printf(" -> invalid username, choose a different one\n")
case errors.Is(err, ErrDuplicate):
fmt.Printf(" -> username taken, choose a different one\n")
}
}
}
Register(""): reason="must be at least 3 characters"
-> invalid username, choose a different one
Register("ab"): reason="must be at least 3 characters"
-> invalid username, choose a different one
Register("admin"): reason="username is reserved"
-> username taken, choose a different one
Register("alice"): OK
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Sentinel Error and errors.Is | — | —/1 | — | — |
| Exercise 2 — Custom Error Type with errors.As | — | —/1 | — | — |
| Exercise 3 — Building an Error Chain | — | —/2 | — | — |
| Exercise 4 — errors.Join for Validation | — | —/2 | — | — |
| Exercise 5 — Named Returns and Deferred Error Decoration | — | —/2 | — | — |
| Exercise 6 — Panic and Recover at a Boundary | — | —/3 | — | — |
| Exercise 7 — Full Error Handling System | — | —/5 | — | — |
| Total | —/16 |
Passing threshold: 10/16 (63%). Aim for 14/16 (87%) before taking the test.