Day 10: Error Handling
Core Concept: Errors Are Values
Go’s error is a plain interface:
type error interface {
Error() string
}
Errors are returned as regular values — there is no exception mechanism. Handle every error at the call site.
Creating Errors
errors.New("something went wrong") // simple
fmt.Errorf("parse failed at line %d: %w", line, err) // with context and wrapping
The %w verb wraps an error — errors.Is and errors.As can unwrap the chain.
Sentinel Errors
Package-level error variables that callers check by identity:
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
)
if errors.Is(err, ErrNotFound) { /* ... */ }
Custom Error Types
Carry structured data in an error:
type ParseError struct {
Line int
Column int
Msg string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Msg)
}
// Retrieve with errors.As:
var pe *ParseError
if errors.As(err, &pe) {
fmt.Printf("error at line %d\n", pe.Line)
}
Error Wrapping Chain
raw := errors.New("connection refused")
layer1 := fmt.Errorf("dial failed: %w", raw)
layer2 := fmt.Errorf("startup: %w", layer1)
errors.Is(layer2, raw) // true — unwraps the chain
errors.Unwrap(layer2) // layer1
The errors Package
| Function | Purpose |
|---|---|
errors.New(text) |
Create a simple error |
errors.Is(err, target) |
Identity check through wrapping chain |
errors.As(err, &target) |
Type check through wrapping chain |
errors.Unwrap(err) |
One level of unwrapping |
Labs
Lab 1: Sentinel Errors
What you’ll practise: defining package-level sentinel errors and checking them with errors.Is.
Task:
Define ErrNotFound and ErrInvalidInput sentinel errors. Write functions that return them and demonstrate errors.Is checking at the call site.
Steps:
- Declare
var ErrNotFound = errors.New("not found")andvar ErrInvalidInput = errors.New("invalid input") - Write
findUser(id int) (string, error)that returnsErrNotFoundwhenid <= 0 - Write
validateAge(age int) errorthat returnsErrInvalidInputwhenage < 0 || age > 150 - In
main, call both functions and useerrors.Isto branch on the specific error
var (
ErrNotFound = errors.New("not found")
ErrInvalidInput = errors.New("invalid input")
)
func findUser(id int) (string, error) {
if id <= 0 {
return "", ErrNotFound
}
return fmt.Sprintf("user-%d", id), nil
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return ErrInvalidInput
}
return nil
}
// In main:
if errors.Is(err, ErrNotFound) {
fmt.Println("user does not exist")
}
Expected output:
findUser(-1): not found
validateAge(200): invalid input
findUser(42): user-42
Checkpoint: errors.Is(err, ErrNotFound) returns true for errors returned by findUser with a non-positive id.
Lab 2: Custom Error Type with errors.As
What you’ll practise: implementing the error interface on a struct and extracting fields with errors.As.
Task:
Define ParseError{Line int, Col int, Msg string} implementing error. Write a parser function that returns it. Use errors.As to extract the line number at the call site.
Steps:
- Define
type ParseError struct { Line, Col int; Msg string } - Implement
func (e *ParseError) Error() stringreturning"line N col C: Msg" - Write
parseToken(s string, line, col int) errorreturning&ParseErrorfor invalid input - At the call site, use
errors.As(err, &pe)and printpe.Line
type ParseError struct {
Line int
Col int
Msg string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Col, e.Msg)
}
func parseToken(s string, line, col int) error {
if s == "" {
return &ParseError{Line: line, Col: col, Msg: "empty token"}
}
return nil
}
// At call site:
var pe *ParseError
if errors.As(err, &pe) {
fmt.Printf("parse failed at line %d\n", pe.Line)
}
Expected output:
line 3 col 7: empty token
parse failed at line 3
Checkpoint: errors.As successfully extracts the *ParseError and its Line field is accessible.
Lab 3: Error Wrapping with %w
What you’ll practise: wrapping errors with fmt.Errorf("%w", err) and verifying errors.Is works through the chain.
Task:
Simulate a layered system: a low-level readDB error gets wrapped by fetchRecord and then by handleRequest. Show that errors.Is and errors.Unwrap work at every level.
Steps:
- Define a sentinel
ErrConnection = errors.New("connection refused") - Write
readDB() errorreturningErrConnection - Write
fetchRecord(id int) errorwrapping withfmt.Errorf("fetchRecord %d: %w", id, err) - Write
handleRequest(id int) errorwrapping again - Call
handleRequestand verifyerrors.Is(err, ErrConnection)istrueat the top level
var ErrConnection = errors.New("connection refused")
func readDB() error { return ErrConnection }
func fetchRecord(id int) error {
if err := readDB(); err != nil {
return fmt.Errorf("fetchRecord %d: %w", id, err)
}
return nil
}
func handleRequest(id int) error {
if err := fetchRecord(id); err != nil {
return fmt.Errorf("handleRequest: %w", err)
}
return nil
}
Expected output:
err: handleRequest: fetchRecord 42: connection refused
errors.Is(ErrConnection): true
Unwrap once: fetchRecord 42: connection refused
Checkpoint: errors.Is(err, ErrConnection) is true even though the error has been wrapped twice.
Lab 4: errors.Join — Collecting Multiple Errors
What you’ll practise: accumulating multiple independent errors and combining them with errors.Join (Go 1.20+).
Task:
Write a validateUser function that checks name, email, and age independently. Collect all validation failures with errors.Join so callers see every problem at once.
Steps:
- Write three validators:
validateName,validateEmail,validateAgeeach returning an error or nil - In
validateUser, call all three, collect non-nil errors into a slice - Use
errors.Join(errs...)to return a combined error - Print the combined error and verify
errors.Isworks for individual sentinels through the join
var (
ErrBadName = errors.New("name required")
ErrBadEmail = errors.New("invalid email")
ErrBadAge = errors.New("age out of range")
)
func validateUser(name, email string, age int) error {
var errs []error
if name == "" {
errs = append(errs, ErrBadName)
}
if !strings.Contains(email, "@") {
errs = append(errs, ErrBadEmail)
}
if age < 0 || age > 150 {
errs = append(errs, ErrBadAge)
}
return errors.Join(errs...)
}
Expected output:
name required
invalid email
age out of range
errors.Is(ErrBadName): true
Checkpoint: errors.Join returns nil when all validators pass. errors.Is finds individual sentinels inside the joined error.
Lab 5: Panic and Recover — safeDiv
What you’ll practise: converting a panic into a returned error using recover inside a deferred function.
Task:
Write safeDiv(a, b int) (result int, err error) that recovers from integer division-by-zero panics and returns an ErrDivisionByZero error instead.
Steps:
- Define
var ErrDivisionByZero = errors.New("division by zero") - In
safeDiv, use a named return and a deferred function that callsrecover() - If
recover()returns a non-nil value, seterr = ErrDivisionByZero - Perform
a / bin the function body; callsafeDiv(10, 0)andsafeDiv(10, 2)in main
var ErrDivisionByZero = errors.New("division by zero")
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = ErrDivisionByZero
}
}()
return a / b, nil
}
Expected output:
10 / 2 = 5, err=<nil>
10 / 0 = 0, err=division by zero
Checkpoint: safeDiv(10, 0) returns 0, ErrDivisionByZero without crashing the program.
Lab 6: Panic vs Error — When to Use Each
What you’ll practise: the Go convention that panics signal programming errors, while returned errors signal expected failure conditions.
Task:
Build two contrasting examples: one where panic is appropriate (programmer error during init) and one where returning an error is correct (runtime failure). Add comments explaining the rule.
Steps:
- Write
mustPositive(n int) intthat panics with a helpful message ifn <= 0— this is a programmer-error guard for invariants - Write
openConfig(path string) (*Config, error)that returns an error when the file is missing — this is a recoverable runtime condition - In
main, usemustPositivewith a valid value; show what happens with an invalid one usingsafeDiv-style recover in a test - Add a comment block explaining: panic in
init/setup for invariants; return errors for expected failures
// mustPositive is called during program setup.
// It panics because a non-positive value here is a programming bug,
// not a runtime condition the caller should handle.
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("mustPositive: got %d, want > 0", n))
}
return n
}
// openConfig returns an error because a missing file is a
// recoverable condition — the caller can log it, use defaults, or retry.
func openConfig(path string) ([]byte, error) {
return os.ReadFile(path)
}
Expected output:
Port: 8080
openConfig: open missing.toml: no such file or directory
Checkpoint: You can articulate in a comment: use panic only for invariant violations detected at startup; use returned errors for all runtime failures callers might handle.
Final Lab (Project): CSV Row Parser
What you’ll practise: combining sentinel errors, custom error types, error wrapping, and errors.Join into a realistic parsing component.
Task:
Parse rows from a CSV string. Define ErrEmptyField and ParseError, write a parseRow function that validates field count and content, and demonstrate errors.Is / errors.As at the call site.
Steps:
- Define
var ErrEmptyField = errors.New("empty required field") - Define
ParseError{Line, Col int, Msg string}implementingerror - Write
parseRow(line string, lineNum int) ([]string, error)that wrapsErrEmptyFieldin aParseErrorwhen a field is blank - Parse several rows — some valid, some invalid — and inspect errors with
errors.Isanderrors.As
func parseRow(line string, lineNum int) ([]string, error) {
fields := strings.Split(line, ",")
if len(fields) != 3 {
return nil, &ParseError{Line: lineNum, Col: 0,
Msg: fmt.Sprintf("expected 3 fields, got %d", len(fields))}
}
for i, f := range fields {
if strings.TrimSpace(f) == "" {
return nil, &ParseError{
Line: lineNum, Col: i + 1,
Msg: fmt.Sprintf("%w", ErrEmptyField),
}
}
}
return fields, nil
}
Expected output:
row 1: [alice 30 engineer]
row 2: parse error line 2 col 2: empty required field
-> errors.Is(ErrEmptyField): true
-> line: 2
Checkpoint: errors.Is(err, ErrEmptyField) is true for blank-field rows; errors.As extracts the line number; valid rows parse cleanly.
Extension ideas: stack multiple errors with errors.Join (Go 1.20+); write a retry wrapper that retries on transient errors.
Official Documentation
errors— New, Is, As, Unwrap, Joinfmt— Errorf with%wwrapping verb- Language Spec: Errors — the built-in error interface
- Effective Go: Errors — error handling patterns
- Go Blog: Error handling and Go — idiomatic error handling
- Go Blog: Working with errors in Go 1.13 — wrapping, Is, As