Skip to Content
Course content

28: Practice Exercise: Building a Custom Error Handling Strategy

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

I've spent a lot of time in production systems where the logs are just filled with "something went wrong." It's the bane of my existence. In Go, the simple if err != nil pattern is great for flow control, but it doesn't tell us why something failed or how we should react to it. Let's build a payment processing module and see where the basic approach falls apart.

The fragility of string matching

Initially, I might just throw some errors out there using errors.New(). Let's look at this quick snippet I wrote for a mock payment gateway:

func ProcessPayment(amount float64) error {
    if amount <= 0 {
        return errors.New("invalid amount")
    }
    if amount > 1000 {
        return errors.New("insufficient funds")
    }
    return nil
}

// At the call site...
err := ProcessPayment(1200)
if err != nil && err.Error() == "insufficient funds" {
    fmt.Println("Asking user for more money...")
}

This works... until it doesn't. What happens if I change the error message to "insufficient funds in account" for better UX? My if statement breaks silently. I'm relying on a string, which is basically a promise that I'll never change a typo. That's a recipe for a 3 AM pager call.

Trying sentinel errors

To fix this, I'll use sentinel errors. I'll define them as package-level variables so the caller can compare them directly. It feels cleaner.

var (
    ErrInvalidAmount    = errors.New("invalid amount")
    ErrInsufficientFunds = errors.New("insufficient funds")
)

func ProcessPayment(amount float64) error {
    if amount <= 0 {
        return ErrInvalidAmount
    }
    if amount > 1000 {
        return ErrInsufficientFunds
    }
    return nil
}

Now I can use errors.Is(err, ErrInsufficientFunds). Much better. But wait—what if I want to tell the user how much they are short? A sentinel error is a static value; I can't attach the current balance or the required amount to it without creating a new error every time, which puts us right back at the string-matching problem.

Building a typed error system

This is where I usually pivot to custom error types. By defining a struct, I can carry metadata along with the error. I'll create a specific type for payment failures.

type PaymentError struct {
    AmountRequested float64
    CurrentBalance  float64
    Code            int
}

func (e *PaymentError) Error() string {
    return fmt.Sprintf("payment failed: requested %.2f, but balance is %.2f (code: %d)", 
        e.AmountRequested, e.CurrentBalance, e.Code)
}

Now, instead of returning a generic error, I can return this struct. But there's a catch. If I wrap this error in another layer of context (like fmt.Errorf("checkout failed: %w", err)), a simple type assertion like err.(*PaymentError) will fail because the error is now wrapped in a different internal Go type.

The power of errors.As

I've learned the hard way that in modern Go, you should almost never use type assertions for errors. Instead, I use errors.As. It recursively unwraps the error chain until it finds a match for the target type.

Let's see this in action with a wrapper function that simulates a high-level checkout process:

func Checkout(amount float64) error {
    err := ProcessPayment(amount)
    if err != nil {
        // We wrap the error to add context, but keep the original error inside
        return fmt.Errorf("checkout process failed: %w", err)
    }
    return nil
}

func main() {
    err := Checkout(1200)
    
    var payErr *PaymentError
    if errors.As(err, &payErr) {
        fmt.Printf("User is short by %.2f\n", payErr.AmountRequested - payErr.CurrentBalance)
    } else {
        fmt.Println("A non-payment error occurred")
    }
}

By using %w in fmt.Errorf and errors.As at the call site, I've built a system that is both descriptive for the logs (thanks to the wrapping) and programmatically actionable for the logic (thanks to the custom struct). We've moved from fragile strings to a robust, type-safe strategy.




📋 Practical Task

Exercise: Implementing a Multi-Tiered Order Validation Pipeline

You are building an e-commerce order validation system. You need to create a custom error handling strategy that distinguishes between different types of validation failures so the frontend can display different UI alerts.

  • Create a custom error struct called ValidationError that includes a Field (string) and a Reason (string).
  • Implement the Error() method for ValidationError.
  • Create a function ValidateOrder(order Order) error. If the Quantity is less than 1, return a ValidationError for the "Quantity" field. If the SKU is empty, return a ValidationError for the "SKU" field.
  • Create a wrapper function ProcessOrder(order Order) error that calls ValidateOrder and wraps any returned error using fmt.Errorf with the %w verb to add the prefix "Order Processing Error: ".
  • In your main function, call ProcessOrder with an invalid order, and use errors.As to extract the ValidationError and print only the Field that caused the failure.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.