Skip to main content
โšก Calmops

Custom Errors and Error Wrapping

Custom Errors and Error Wrapping

Go’s error handling philosophy is simple: errors are values. This guide covers creating custom error types and wrapping errors with context, which are essential for robust error handling in Go.

Basic Error Interface

Understanding the Error Interface

package main

import "fmt"

// The error interface is defined as:
// type error interface {
//     Error() string
// }

// Any type that implements Error() string is an error
type MyError struct {
    Message string
}

func (e *MyError) Error() string {
    return e.Message
}

func main() {
    var err error = &MyError{Message: "something went wrong"}
    fmt.Println(err)  // something went wrong
}

Creating Custom Error Types

Simple Custom Error

package mypackage

import "fmt"

// ValidationError represents a validation error
type ValidationError struct {
    Field   string
    Message string
}

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

func ValidateEmail(email string) error {
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Message: "email is required",
        }
    }
    if !contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "email must contain @",
        }
    }
    return nil
}

func contains(s, substr string) bool {
    // Implementation
    return true
}

Error with Additional Context

package database

import "fmt"

// DatabaseError represents a database error
type DatabaseError struct {
    Operation string
    Table     string
    Err       error
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("database error: %s on table %s: %v", e.Operation, e.Table, e.Err)
}

// Unwrap returns the underlying error
func (e *DatabaseError) Unwrap() error {
    return e.Err
}

func GetUser(id int) error {
    // Simulate database error
    err := fmt.Errorf("connection refused")
    return &DatabaseError{
        Operation: "SELECT",
        Table:     "users",
        Err:       err,
    }
}

Error with Stack Trace

package mypackage

import (
    "fmt"
    "runtime"
)

// ErrorWithStack represents an error with stack trace
type ErrorWithStack struct {
    Message string
    Stack   string
}

func (e *ErrorWithStack) Error() string {
    return fmt.Sprintf("%s\nStack:\n%s", e.Message, e.Stack)
}

func NewErrorWithStack(message string) *ErrorWithStack {
    buf := make([]byte, 4096)
    n := runtime.Stack(buf, false)
    return &ErrorWithStack{
        Message: message,
        Stack:   string(buf[:n]),
    }
}

Error Wrapping

Using fmt.Errorf with %w

package main

import (
    "fmt"
    "os"
)

func main() {
    // Wrap error with context
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        // โœ… Good: Wrap error with context
        wrappedErr := fmt.Errorf("failed to open file: %w", err)
        fmt.Println(wrappedErr)
        
        // โŒ Bad: Lose error context
        // fmt.Println(err)
    }
}

Unwrapping Errors

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        wrappedErr := fmt.Errorf("failed to open file: %w", err)
        
        // Unwrap to get original error
        originalErr := errors.Unwrap(wrappedErr)
        fmt.Println("Original:", originalErr)
        
        // Check if it's a specific error
        if errors.Is(wrappedErr, os.ErrNotExist) {
            fmt.Println("File not found")
        }
    }
}

Error Chain

package main

import (
    "errors"
    "fmt"
)

func operation1() error {
    return fmt.Errorf("operation1 failed")
}

func operation2() error {
    err := operation1()
    if err != nil {
        return fmt.Errorf("operation2 failed: %w", err)
    }
    return nil
}

func operation3() error {
    err := operation2()
    if err != nil {
        return fmt.Errorf("operation3 failed: %w", err)
    }
    return nil
}

func main() {
    err := operation3()
    if err != nil {
        fmt.Println(err)
        // Output: operation3 failed: operation2 failed: operation1 failed
        
        // Unwrap the chain
        for err != nil {
            fmt.Println("Error:", err)
            err = errors.Unwrap(err)
        }
    }
}

Error Checking

Using errors.Is

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        wrappedErr := fmt.Errorf("failed to open file: %w", err)
        
        // โœ… Good: Use errors.Is for wrapped errors
        if errors.Is(wrappedErr, os.ErrNotExist) {
            fmt.Println("File not found")
        }
        
        // โŒ Bad: Direct comparison doesn't work with wrapped errors
        // if wrappedErr == os.ErrNotExist {
        //     fmt.Println("File not found")
        // }
    }
}

Using errors.As

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        wrappedErr := fmt.Errorf("failed to open file: %w", err)
        
        // Extract specific error type
        var pathErr *os.PathError
        if errors.As(wrappedErr, &pathErr) {
            fmt.Printf("Path error: %s\n", pathErr.Path)
        }
    }
}

Practical Examples

Validation Error

package validation

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

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

func ValidateUser(name, email string) error {
    if name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "name is required",
        }
    }
    
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Message: "email is required",
        }
    }
    
    return nil
}

func main() {
    err := ValidateUser("", "[email protected]")
    if err != nil {
        fmt.Println(err)  // validation error: name - name is required
    }
}

Database Error

package database

import (
    "fmt"
)

type DatabaseError struct {
    Operation string
    Table     string
    Err       error
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("database error: %s on %s: %v", e.Operation, e.Table, e.Err)
}

func (e *DatabaseError) Unwrap() error {
    return e.Err
}

func GetUser(id int) error {
    // Simulate database error
    err := fmt.Errorf("connection refused")
    return &DatabaseError{
        Operation: "SELECT",
        Table:     "users",
        Err:       err,
    }
}

func main() {
    err := GetUser(1)
    if err != nil {
        fmt.Println(err)
        // Output: database error: SELECT on users: connection refused
    }
}

API Error

package api

import (
    "fmt"
)

type APIError struct {
    StatusCode int
    Message    string
    Err        error
}

func (e *APIError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("API error %d: %s (%v)", e.StatusCode, e.Message, e.Err)
    }
    return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}

func (e *APIError) Unwrap() error {
    return e.Err
}

func CallAPI() error {
    // Simulate API error
    return &APIError{
        StatusCode: 500,
        Message:    "Internal Server Error",
        Err:        fmt.Errorf("database connection failed"),
    }
}

func main() {
    err := CallAPI()
    if err != nil {
        fmt.Println(err)
        // Output: API error 500: Internal Server Error (database connection failed)
    }
}

Error Handling Patterns

Sentinel Errors

package mypackage

import "errors"

// Define sentinel errors
var (
    ErrNotFound = errors.New("not found")
    ErrInvalid = errors.New("invalid")
    ErrUnauthorized = errors.New("unauthorized")
)

func GetUser(id int) error {
    if id <= 0 {
        return ErrInvalid
    }
    // Simulate not found
    return ErrNotFound
}

func main() {
    err := GetUser(0)
    if err == ErrInvalid {
        fmt.Println("Invalid ID")
    }
}

Error Wrapping with Sentinel

package main

import (
    "errors"
    "fmt"
)

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

func GetUser(id int) error {
    if id <= 0 {
        return fmt.Errorf("invalid id: %w", ErrNotFound)
    }
    return nil
}

func main() {
    err := GetUser(0)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
}

Best Practices

โœ… Good Practices

  1. Implement Error() method - For custom error types
  2. Wrap errors with context - Use fmt.Errorf with %w
  3. Use errors.Is and errors.As - For error checking
  4. Implement Unwrap() method - For error chains
  5. Use sentinel errors - For specific error types
  6. Add context to errors - Include relevant information
  7. Don’t ignore errors - Always handle them
  8. Log errors appropriately - Include stack traces when needed

โŒ Anti-Patterns

// โŒ Bad: Ignore errors
file, _ := os.Open("file.txt")

// โœ… Good: Handle errors
file, err := os.Open("file.txt")
if err != nil {
    return fmt.Errorf("failed to open file: %w", err)
}

// โŒ Bad: Lose error context
return err

// โœ… Good: Wrap with context
return fmt.Errorf("operation failed: %w", err)

// โŒ Bad: Direct comparison with wrapped errors
if wrappedErr == os.ErrNotExist {
    // Won't work
}

// โœ… Good: Use errors.Is
if errors.Is(wrappedErr, os.ErrNotExist) {
    // Works correctly
}

Summary

Custom errors and error wrapping are essential:

  • Implement custom error types
  • Wrap errors with context
  • Use errors.Is and errors.As
  • Implement Unwrap() for error chains
  • Use sentinel errors for specific types
  • Always handle errors appropriately
  • Add meaningful context to errors

Master error handling for robust Go applications.

Comments