Skip to Content
Course content

24: Error Handling Idioms

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're running a professional kitchen. You don't just throw all the ingredients in a pot, turn on the heat, and walk away for an hour, hoping for the best. If you did, and the sauce broke or the meat burned, you wouldn't know exactly when or why it happened—you'd just have a ruined dinner. Instead, you taste the food at every single stage. You check the sear on the steak; you taste the reduction. If the sauce is too salty, you handle it right then—either by adding a potato to absorb the salt or by starting over—before that mistake moves down the line to the plating station.

Go treats errors exactly like that kitchen workflow. We don't use "try-catch" blocks to wrap a giant chunk of code and hope we catch whatever flies out. Instead, we "taste" for errors after almost every single operation. It feels repetitive at first, but it means you never lose track of exactly where a failure happened.

Stop the Bleeding Early

In Go, an error is just a value. When a function can fail, it returns that error as its last return value. Your job is to check it immediately. I've seen a lot of beginners try to ignore this or push the error handling to the end of the function, but that's a recipe for disaster. You want to handle the error as close to the source as possible.

type User struct {
    ID   int
    Name string
}

func FetchUser(id int) (*User, error) {
    // Imagine this calls a database
    if id == 0 {
        return nil, errors.New("invalid user ID")
    }
    return &User{ID: id, Name: "Alice"}, nil
}

func ProcessUser() error {
    user, err := FetchUser(0)
    if err != nil {
        // We "taste" the error immediately. 
        // We don't proceed to use 'user' because it's nil.
        return fmt.Errorf("could not process user: %v", err)
    }
    fmt.Println("Processing", user.Name)
    return nil
}

Adding Context to the Trail

If you just return err as-is, your logs will eventually be filled with generic messages like "file not found". When your app is running in production, "file not found" is useless. Which file? Why were we looking for it? This is where wrapping comes in.

By using fmt.Errorf with the %w verb, you wrap the original error. It's like adding a note to the dish: "The sauce is too salty (because the sous-chef added double the salt)." You keep the original error for the computer to check, but add a human-readable trail for yourself.

func LoadConfig(path string) error {
    _, err := os.Open(path)
    if err != nil {
        // %w wraps the error, allowing us to unwrap it later
        return fmt.Errorf("failed to load config from %s: %w", path, err)
    }
    return nil
}

Distinguishing the Flavor of Failure

Sometimes, your code needs to behave differently depending on why something failed. For example, if a user isn't found, you might return a 404; if the database is down, you return a 500. For this, we use Sentinel Errors—predefined variables that act as constants.

To check for these, don't use == if you've wrapped your errors. Use errors.Is. It recursively unwraps the error chain to see if the sentinel is hiding inside.

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

func GetRecord(id string) error {
    if id == "" {
        return fmt.Errorf("search failed: %w", ErrNotFound)
    }
    return nil
}

func HandleRequest(id string) {
    err := GetRecord(id)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Sending 404 to client...")
    } else if err != nil {
        fmt.Println("Sending 500 to client...")
    }
}

If you need to access specific fields from a custom error struct (like an error code or a retry-after timestamp), use errors.As. It's essentially a type-safe cast that looks through the wrapped layers to find a specific error type. I personally find this much cleaner than the old-school type assertion err.(*MyCustomError) because it handles wrapped errors automatically.




📋 Practical Task

Build a Robust Order Validation System

You are building a simplified checkout system. Your goal is to implement a validation chain that distinguishes between different types of failures using Go's error idioms.

Requirements:

  • Define a sentinel error called ErrOutOfStock.
  • Define a custom error struct called ValidationError that includes a Field string (e.g., "Email" or "ZipCode") and the original error.
  • Create a function ValidateInventory(item string) error that returns ErrOutOfStock if the item is "PS5", otherwise returns nil.
  • Create a function ValidateShipping(zip string) error that returns a ValidationError if the zip code is empty.
  • Create a main ProcessOrder function that calls both validations. If a validation fails, it must wrap the error with the context "order processing failed" using %w.
  • In your main function, call ProcessOrder and use errors.Is to detect if the failure was specifically due to stock issues, and errors.As to detect if it was a validation error so you can print which specific field caused the problem.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.