Lesson 14/41 · Composite Data Types & Object-Oriented Design Basics m02-l05

Error Handling: The error Interface, Error Wrapping and Sentinel Errors

Module 2: Composite Data Types & Object-Oriented Design | Lesson: m02-l05 | Level: Beginner After this lesson you will be able to: use Go's standard error handling pattern, understand the error chain mechanism, and correctly use panic/recover for unrecoverable exceptions.

Learning Objectives

  • Explain the definition of the error interface and its central role in Go error handling.
  • Use fmt.Errorf with %w to wrap errors, and use errors.Is and errors.As to inspect the error chain.
  • Distinguish sentinel errors from custom error types, and understand when to use errors.Join.
  • Correctly understand the boundaries of panic and recover, and avoid abusing them to crash the program.

Core Concepts

2.1 The error Interface and Custom Error Types

error is the standard interface Go uses to represent an error state. It is a predeclared identifier (listed in the builtin docs, no import needed) and contains exactly one method:

type error interface {
    Error() string
}

Why this design? Go has no exception class hierarchy like Java or Python. Errors are ordinary values that can be passed, stored and compared like any other data. This design makes error handling explicit and controlled, avoiding the tangled control flow that implicit exception catching brings.

Custom error types Although errors.New can create simple errors, in production we usually need to carry more information (such as an HTTP status code, an error code, a stack trace, etc.). For that we define custom structs that implement the error interface.

To support finer-grained error matching, a custom error type can implement an Is(error) bool method. When this method exists, errors.Is calls it preferentially when checking that error type, allowing a custom error to logically match a sentinel error rather than relying solely on pointer equality.

type MyError struct {
    Code int
    Msg  string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("code=%d, msg=%s", e.Code, e.Msg)
}

// Optional: implement Is to support logical matching in errors.Is
func (e *MyError) Is(target error) bool {
    // e.g., match a specific sentinel error
    return target == ErrSpecificError
}

2.2 Error Wrapping and the Error Chain

In multi-layer function calls, an error returned by a low-level function often needs to be passed upward. If it is returned as-is, the caller loses the original context (for example, whether the "database query failed" was caused by a "connection timeout" or a "SQL syntax error").

Error wrapping Go 1.13 introduced error wrapping. Using fmt.Errorf with the %w verb wraps the original error inside a new error while keeping a reference to the original error.

// %w was introduced in Go 1.13
err := fmt.Errorf("failed to process: %w", originalErr)

Improvement in Go 1.20 Starting in Go 1.20, fmt.Errorf allows multiple %w verbs in the same format string, supporting more complex error chains.

Inspecting the error chain: errors.Is and errors.As - errors.Is(err, target error) bool: walks up err's wrap chain and reports whether any error in the chain is equal to target. It is mainly used to check for sentinel errors. - errors.As(err, target interface{}) bool: walks up err's wrap chain, finds the first error that can be assigned to the type target points to, and writes it into target. target must be a non-nil pointer to some error type or to the error interface. It is mainly used to extract specific fields from a custom error.

Sentinel errors A sentinel error is a predeclared, globally unique error variable, usually representing a specific business error state. Because errors.Is uses pointer equality for pointer types, sentinel errors are typically defined as package-level variables.

errors.Join (Go 1.20+) When you need to return several errors at once (for example, partial failures in a batch operation), use errors.Join. It returns a composite error containing all of them, and errors.Is can match any one of them.

2.3 The Boundaries of panic and recover

panic triggers a program crash and recover restores from it. They are typically used for unrecoverable exceptions (such as nil pointer dereference, array index out of bounds), not for business logic errors.

Two hard rules for recover 1. Must be called directly: recover only works when called directly inside a deferred function. If recover is wrapped inside another ordinary function, it returns nil and cannot catch the panic. 2. Does not cross goroutines: a panic unwinds only the current goroutine's call stack. Other goroutines cannot recover this goroutine's panic. Therefore every goroutine that might panic must carry its own defer/recover.

Example Code

Example 1: Error Wrapping and Inspection

This example shows how to wrap errors with fmt.Errorf and inspect the chain with errors.Is and errors.As.

package main

import (
    "errors"
    "fmt"
)

// Sentinel error: record not found
var ErrRecordNotFound = errors.New("record not found")

// Custom error type: a database error
type DatabaseError struct {
    Code int
    Msg  string
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("db error: code=%d, msg=%s", e.Code, e.Msg)
}

// queryDatabase simulates a database query and may return a sentinel or a custom error
func queryDatabase(id int) error {
    if id == 404 {
        return ErrRecordNotFound
    }
    if id == 500 {
        return &DatabaseError{Code: 500, Msg: "internal server error"}
    }
    return nil
}

func main() {
    // Scenario 1: check a sentinel error
    err := queryDatabase(404)
    if errors.Is(err, ErrRecordNotFound) {
        fmt.Println("Caught sentinel error: record not found")
    }

    // Scenario 2: check a custom error type
    err = queryDatabase(500)
    var dbErr *DatabaseError
    if errors.As(err, &dbErr) {
        fmt.Printf("Caught custom error: code=%d\n", dbErr.Code)
    }

    // Scenario 3: error wrapping and chained inspection
    wrappedErr := fmt.Errorf("failed to query: %w", err)

    // The correct approach: use errors.As to check whether the wrap chain contains DatabaseError
    var targetDBErr *DatabaseError
    if errors.As(wrappedErr, &targetDBErr) {
        fmt.Printf("Wrapped error contains DatabaseError: code=%d\n", targetDBErr.Code)
    }
}

Expected output:

Caught sentinel error: record not found
Caught custom error: code=500
Wrapped error contains DatabaseError: code=500

Example 2: The Boundaries of panic and recover

This example shows the correct usage of recover, the requirement to call it directly, and the fact that it does not cross goroutines.

package main

import (
    "fmt"
    "sync"
)

// safeFunc: recover works when called directly in a defer
func safeFunc() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered in safeFunc: %v\n", r)
        }
    }()
    panic("panic in safeFunc")
}

// recoverInNormalFunc: recover is NOT called directly in a defer
func recoverInNormalFunc() interface{} {
    return recover() // returns nil, because no panic is currently unwinding
}

// goroutinePanic: panic does not cross goroutines
func goroutinePanic() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered in main goroutine: %v\n", r)
        }
    }()

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        defer func() {
            if r := recover(); r != nil {
                fmt.Printf("Recovered in child goroutine: %v\n", r)
            }
        }()
        panic("panic in child goroutine")
    }()
    wg.Wait()
}

func main() {
    fmt.Println("=== Test 1: Direct defer recover ===")
    safeFunc()

    fmt.Println("\n=== Test 2: Recover in normal function ===")
    // Calling recoverInNormalFunc does not catch any panic, because it is not a defer.
    // We do not panic here, so recover returns nil.
    r := recoverInNormalFunc()
    fmt.Printf("Recover result: %v\n", r)

    fmt.Println("\n=== Test 3: Goroutine panic ===")
    goroutinePanic()
}

Expected output:

=== Test 1: Direct defer recover ===
Recovered in safeFunc: panic in safeFunc

=== Test 2: Recover in normal function ===
Recover result: <nil>

=== Test 3: Goroutine panic ===
Recovered in child goroutine: panic in child goroutine

Common Mistakes & Pitfalls

  • Bad code: using errors.Is(err, &MyError{}) to check a custom error. Why: errors.Is uses pointer equality for pointer types. &MyError{} creates a new pointer every time it is evaluated, which is not equal to the wrapped error's pointer. Correct approach: use errors.As(err, &target) to check the type, or define a global sentinel error variable and use errors.Is.

  • Bad code: calling recover() inside an ordinary function to try to catch a panic. Why: recover only works inside a function called directly by defer. If recover is wrapped in another function, it returns nil. Correct approach: make sure defer func() { recover() }() is written directly in the function that may panic.

  • Bad code: using a composite literal directly in an if condition without parentheses, e.g. if err == &MyError{Code: 500}. Why: Go's grammar requires composite literals to be parenthesized when they appear in the condition position of if/for/switch; otherwise the parser treats it as a statement block and it is a syntax error. Correct approach: write if err == (&MyError{Code: 500}), but it is better to define a variable first and compare against that.

Best Practices

  1. Prefer sentinel errors: for common, predictable business errors (such as "user not found", "permission denied"), define package-level sentinel variables so callers can use errors.Is for precise checks.
  2. Use errors.As to extract custom errors: when an error needs to carry extra information (an error code, details), define a custom error type and use errors.As at the call site to extract it — rather than relying on string matching.
  3. Avoid abusing panic: reserve panic for unrecoverable exceptions (internal logic bugs, nil pointers). Business logic errors should be handled by returning error.
  4. Give every goroutine its own recover: if a goroutine may panic, it must use defer/recover internally so the whole program does not crash.
  5. Use errors.Join for batch errors: in batch-processing scenarios, when you want to collect all errors instead of stopping at the first one, use errors.Join to return a composite error.

Exercises

  1. Concept question: explain the difference between errors.Is and errors.As, and describe when to use each.
  2. Coding exercise: define a custom error type ValidationError with Field and Message fields. Write a function validate that returns a ValidationError when the input is empty. In main, use errors.As to extract the error and print its fields.
  3. Advanced/debugging exercise: analyze the following code, point out the problems, and fix them.
package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

type DBError struct {
    Code int
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db error: %d", e.Code)
}

func query(id int) error {
    if id == 404 {
        return ErrNotFound
    }
    if id == 500 {
        return &DBError{Code: 500}
    }
    return nil
}

func main() {
    err := query(404)

    // Problem 1: this cannot correctly check ErrNotFound
    if errors.Is(err, &DBError{}) {
        fmt.Println("Found DBError")
    }

    // Problem 2: this cannot correctly check DBError
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Found NotFound")
    }
}

Reference Answers

Reference Answers (click to expand / collapse)

Question 1

errors.Is checks whether the error chain contains an error equal to a target error; it fits checking sentinel errors (globally unique variables). errors.As walks the chain to find the first error assignable to the target pointer type; it fits extracting specific fields from a custom error.

Question 2

package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: field=%s, msg=%s", e.Field, e.Message)
}

func validate(name string) error {
    if name == "" {
        return &ValidationError{Field: "name", Message: "cannot be empty"}
    }
    return nil
}

func main() {
    err := validate("")
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
    }
}

Question 3

Problem analysis: 1. errors.Is(err, &DBError{}) cannot match, because &DBError{} is a freshly created pointer that is not equal to the pointer returned by query. 2. errors.Is(err, ErrNotFound) should match ErrNotFound: here err is the ErrNotFound returned by query(404), so that line does match. The logical error the exercise wants fixed is using errors.Is to check a custom error type.

Fixed code:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

type DBError struct {
    Code int
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db error: %d", e.Code)
}

func query(id int) error {
    if id == 404 {
        return ErrNotFound
    }
    if id == 500 {
        return &DBError{Code: 500}
    }
    return nil
}

func main() {
    err := query(404)

    // Fix 1: use errors.Is to check the sentinel error
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Found NotFound")
    }

    // Fix 2: use errors.As to check the custom error type
    var dbErr *DBError
    if errors.As(err, &dbErr) {
        fmt.Printf("Found DBError: code=%d\n", dbErr.Code)
    }
}

Lesson Summary

  • error is Go's standard error interface; custom errors implement Error() string.
  • Wrap errors with fmt.Errorf and %w; use errors.Is to check sentinel errors and errors.As to extract custom errors.
  • errors.Join (Go 1.20+) combines multiple errors.
  • panic and recover are only for unrecoverable exceptions; recover must be called directly in a defer and does not cross goroutines.

Next lesson preview: generics — type parameters, constraints like ~T and comparable, and when to choose generics over interfaces.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com