Web Analytics

Error Basics & Custom Errors

Intermediate ~35 min read

Go treats errors as values, not exceptions. This simple but powerful approach makes error handling explicit and predictable. In this lesson, you'll learn Go's error philosophy, how to create and handle errors, and how to design custom error types.

The Error Interface

In Go, errors are just values that implement the error interface:

type error interface {
    Error() string
}
Output
Click Run to execute your code
Go's Error Philosophy:
  • Errors are values - Not exceptions to be thrown/caught
  • Explicit handling - Check errors where they occur
  • Return errors - Functions return errors as values
  • No hidden control flow - No try/catch blocks

Creating Errors

import "errors"

// Simple error
err := errors.New("something went wrong")

// Formatted error
import "fmt"
err := fmt.Errorf("invalid value: %d", value)

Error Handling Patterns

1. Check and Return

Output
Click Run to execute your code
Best Practice: Handle errors immediately after they occur. Don't ignore them! The pattern if err != nil { return err } is idiomatic Go.

2. Early Return

func processData(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return err  // Early return on error
    }
    
    result, err := transform(data)
    if err != nil {
        return err
    }
    
    err = save(result)
    if err != nil {
        return err
    }
    
    return nil  // Success
}

3. Error with Context

func readConfig(filename string) (*Config, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to read config: %w", err)
    }
    
    var config Config
    err = json.Unmarshal(data, &config)
    if err != nil {
        return nil, fmt.Errorf("failed to parse config: %w", err)
    }
    
    return &config, nil
}

Custom Error Types

Create custom errors by implementing the error interface:

Output
Click Run to execute your code
When to Use Custom Errors:
  • Need to include additional context (fields)
  • Want to distinguish error types programmatically
  • Need structured error information
  • Building libraries or APIs

Sentinel Errors

Sentinel errors are predefined error values for specific conditions:

Output
Click Run to execute your code

Standard Library Sentinel Errors

import (
    "io"
    "os"
)

// io package
io.EOF           // End of file
io.ErrClosedPipe // Pipe closed
io.ErrUnexpectedEOF

// os package
os.ErrNotExist   // File doesn't exist
os.ErrExist      // File already exists
os.ErrPermission // Permission denied
Pro Tip: Use errors.Is() to check for sentinel errors, not ==. This works even when errors are wrapped!

Checking Error Types

Using errors.As()

import "errors"

var validationErr *ValidationError
if errors.As(err, &validationErr) {
    // err is a ValidationError
    fmt.Println("Field:", validationErr.Field)
    fmt.Println("Message:", validationErr.Message)
}

// Works with wrapped errors too!
err := fmt.Errorf("processing failed: %w", &ValidationError{
    Field: "age",
    Message: "must be positive",
})

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println("Found:", ve.Field)  // Works!
}

Common Error Patterns

1. Multiple Return Values

// Return result and error
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Usage
result, err := divide(10, 0)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

2. Named Return Values

func readFile(name string) (data []byte, err error) {
    f, err := os.Open(name)
    if err != nil {
        return  // Returns nil, err
    }
    defer f.Close()
    
    data, err = io.ReadAll(f)
    return  // Returns data, err
}

3. Error Aggregation

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    var msgs []string
    for _, err := range m.Errors {
        msgs = append(msgs, err.Error())
    }
    return strings.Join(msgs, "; ")
}

func (m *MultiError) Add(err error) {
    if err != nil {
        m.Errors = append(m.Errors, err)
    }
}

Common Mistakes

1. Ignoring errors

// โŒ Wrong - ignoring error
data, _ := os.ReadFile("config.json")

// โœ… Correct - handle error
data, err := os.ReadFile("config.json")
if err != nil {
    log.Fatal(err)
}

2. Using panic for normal errors

// โŒ Wrong - panic for expected errors
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero")
    }
    return a / b
}

// โœ… Correct - return error
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

3. Not adding context to errors

// โŒ Less helpful
func loadConfig() error {
    _, err := os.ReadFile("config.json")
    return err  // Where did this error come from?
}

// โœ… Better - add context
func loadConfig() error {
    _, err := os.ReadFile("config.json")
    if err != nil {
        return fmt.Errorf("failed to load config: %w", err)
    }
    return nil
}

4. Comparing errors with ==

// โŒ Wrong - doesn't work with wrapped errors
if err == io.EOF {
    // Won't match if err is wrapped
}

// โœ… Correct - use errors.Is()
if errors.Is(err, io.EOF) {
    // Works even if wrapped!
}

Exercise: User Validation

Task: Create a user validation system with custom errors.

Requirements:

  • Create a ValidationError type with Field and Message
  • Create a validateUser function
  • Validate: name not empty, age >= 0, email contains @
  • Return appropriate validation errors
Show Solution
package main

import (
    "errors"
    "fmt"
    "strings"
)

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

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

// User represents a user
type User struct {
    Name  string
    Age   int
    Email string
}

// validateUser validates a user
func validateUser(u User) error {
    if u.Name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "cannot be empty",
        }
    }
    
    if u.Age < 0 {
        return &ValidationError{
            Field:   "age",
            Message: "must be non-negative",
        }
    }
    
    if !strings.Contains(u.Email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "must contain @",
        }
    }
    
    return nil
}

func main() {
    // Test cases
    users := []User{
        {Name: "Alice", Age: 25, Email: "[email protected]"},
        {Name: "", Age: 30, Email: "[email protected]"},
        {Name: "Carol", Age: -5, Email: "[email protected]"},
        {Name: "Dave", Age: 40, Email: "invalid-email"},
    }
    
    for i, user := range users {
        fmt.Printf("Validating user %d: %+v\n", i+1, user)
        
        err := validateUser(user)
        if err != nil {
            // Check if it's a ValidationError
            var ve *ValidationError
            if errors.As(err, &ve) {
                fmt.Printf("  โŒ Invalid %s: %s\n", ve.Field, ve.Message)
            } else {
                fmt.Printf("  โŒ Error: %v\n", err)
            }
        } else {
            fmt.Println("  โœ… Valid")
        }
        fmt.Println()
    }
}

Summary

  • Errors are values in Go, not exceptions
  • error interface requires only Error() string method
  • Check errors explicitly with if err != nil
  • Create errors with errors.New() or fmt.Errorf()
  • Custom errors implement the error interface
  • Sentinel errors are predefined error values
  • errors.Is() checks for specific errors
  • errors.As() extracts custom error types

What's Next?

Now that you understand error basics and custom errors, you're ready to learn about Error Wrapping & Best Practices. You'll discover how to wrap errors for better context and explore advanced error handling patterns.