Foundation ⏱ 6 min read

03 · Error Handling

No try/catch — errors are values you return and check

Node.js background? Instead of throw new Error() and try/catch, Go returns error as a second value. The caller always checks it — nothing is caught automatically.

The Basic Pattern

result, err := doSomething()
if err != nil {
  return fmt.Errorf("doSomething failed: %w", err)
}
// use result safely here

The %w verb wraps the original error so callers can inspect it. Always add context when wrapping — it builds a chain like a stack trace.

Real Code — lp-coupon-api

// internal/services/coupon_service.go
func (s *CouponService) GetCouponByID(ctx context.Context, id string) (*models.Coupon, error) {
  coupon, err := s.couponRepo.GetByID(ctx, id)
  if err != nil {
    return nil, fmt.Errorf("GetCouponByID: %w", err)
  }
  return coupon, nil
}

Notice: the handler calls the service, the service calls the repo, each layer wraps the error with context. By the time the error reaches the handler, it has a full breadcrumb trail.

errors.Is and errors.As

Use these to inspect wrapped errors rather than string matching.

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

// Somewhere deep in the call chain, this error was wrapped:
// fmt.Errorf("GetCouponByID: %w", ErrNotFound)

// Check if the chain contains ErrNotFound:
if errors.Is(err, ErrNotFound) {
  // return HTTP 404
}

// Extract a typed error:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
  fmt.Println(validationErr.Field)
}

Creating Custom Errors

type ValidationError struct {
  Field   string
  Message string
}

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

Any type with an Error() string method satisfies the error interface.

panic — When to Use It

Do not use panic for business logic errors. panic is for programmer mistakes (impossible state, nil where nil should never occur). Always return an error instead.
// BAD — don't do this
func GetUser(id string) *User {
  user, err := db.Find(id)
  if err != nil {
    panic(err) // kills the server!
  }
  return user
}

// GOOD
func GetUser(id string) (*User, error) {
  user, err := db.Find(id)
  if err != nil {
    return nil, fmt.Errorf("GetUser: %w", err)
  }
  return user, nil
}

Fiber's recover middleware catches panics at the HTTP boundary, but you should never rely on it for expected error cases.

Key Takeaways