Skip to main content
โšก Calmops

Custom Error Types in Go: Structured Error Handling

Introduction

Go’s built-in error interface only carries a string message. For production applications, you often need structured errors that carry additional context โ€” HTTP status codes, field names, error codes, or nested causes. This guide shows how to build custom error types that integrate cleanly with Go’s error handling ecosystem.

The Error Interface

Any type implementing Error() string satisfies the error interface:

type error interface {
    Error() string
}

Basic Custom Error

package main

import (
    "errors"
    "fmt"
)

// Custom error with status code
type AppError struct {
    Code    int
    Message string
    Err     error  // wrapped underlying error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// Implement Unwrap so errors.Is/As work through the chain
func (e *AppError) Unwrap() error {
    return e.Err
}

func main() {
    err := &AppError{
        Code:    404,
        Message: "user not found",
        Err:     errors.New("no rows in result set"),
    }

    fmt.Println(err)
    // => [404] user not found: no rows in result set

    // Type assertion to access fields
    var appErr *AppError
    if errors.As(err, &appErr) {
        fmt.Printf("Status code: %d\n", appErr.Code)
    }
}

HTTP Error Type

A common pattern for web applications:

package apierr

import "fmt"

type Error struct {
    StatusCode int    `json:"-"`
    Code       string `json:"code"`
    Message    string `json:"message"`
    err        error
}

func (e *Error) Error() string {
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

func (e *Error) Unwrap() error { return e.err }

// Constructor functions for common errors
func NotFound(resource string) *Error {
    return &Error{
        StatusCode: 404,
        Code:       "NOT_FOUND",
        Message:    resource + " not found",
    }
}

func Unauthorized(msg string) *Error {
    return &Error{
        StatusCode: 401,
        Code:       "UNAUTHORIZED",
        Message:    msg,
    }
}

func BadRequest(msg string) *Error {
    return &Error{
        StatusCode: 400,
        Code:       "BAD_REQUEST",
        Message:    msg,
    }
}

func Internal(err error) *Error {
    return &Error{
        StatusCode: 500,
        Code:       "INTERNAL_ERROR",
        Message:    "an internal error occurred",
        err:        err,
    }
}

func Wrap(err error, statusCode int, code, message string) *Error {
    return &Error{
        StatusCode: statusCode,
        Code:       code,
        Message:    message,
        err:        err,
    }
}
// Usage in handlers
func getUser(w http.ResponseWriter, r *http.Request) error {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        return apierr.BadRequest("invalid user ID")
    }

    user, err := db.GetUser(id)
    if errors.Is(err, sql.ErrNoRows) {
        return apierr.NotFound("user")
    }
    if err != nil {
        return apierr.Internal(err)
    }

    return json.NewEncoder(w).Encode(user)
}

// Middleware that handles errors
func errorHandler(h func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        err := h(w, r)
        if err == nil {
            return
        }

        w.Header().Set("Content-Type", "application/json")

        var apiErr *apierr.Error
        if errors.As(err, &apiErr) {
            w.WriteHeader(apiErr.StatusCode)
            json.NewEncoder(w).Encode(apiErr)
        } else {
            log.Printf("unhandled error: %v", err)
            w.WriteHeader(500)
            json.NewEncoder(w).Encode(map[string]string{
                "code":    "INTERNAL_ERROR",
                "message": "an internal error occurred",
            })
        }
    }
}

Validation Error Type

type FieldError struct {
    Field   string
    Message string
}

func (e *FieldError) Error() string {
    return fmt.Sprintf("field %q: %s", e.Field, e.Message)
}

type ValidationErrors []*FieldError

func (e ValidationErrors) Error() string {
    msgs := make([]string, len(e))
    for i, fe := range e {
        msgs[i] = fe.Error()
    }
    return strings.Join(msgs, "; ")
}

func (e ValidationErrors) HasErrors() bool {
    return len(e) > 0
}

// Usage
func validateUser(u User) error {
    var errs ValidationErrors

    if u.Name == "" {
        errs = append(errs, &FieldError{Field: "name", Message: "is required"})
    }
    if u.Email == "" {
        errs = append(errs, &FieldError{Field: "email", Message: "is required"})
    } else if !strings.Contains(u.Email, "@") {
        errs = append(errs, &FieldError{Field: "email", Message: "is invalid"})
    }
    if u.Age < 0 || u.Age > 150 {
        errs = append(errs, &FieldError{Field: "age", Message: "must be between 0 and 150"})
    }

    if errs.HasErrors() {
        return errs
    }
    return nil
}

// Caller
err := validateUser(user)
var valErrs ValidationErrors
if errors.As(err, &valErrs) {
    for _, fe := range valErrs {
        fmt.Printf("  %s: %s\n", fe.Field, fe.Message)
    }
}

Database Error Type

type DBError struct {
    Op    string  // operation: "query", "insert", "update"
    Table string
    Err   error
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db %s on %s: %v", e.Op, e.Table, e.Err)
}

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

// Sentinel errors
var (
    ErrNotFound  = errors.New("record not found")
    ErrDuplicate = errors.New("duplicate key")
)

func (s *Store) GetUser(id int) (*User, error) {
    var user User
    err := s.db.QueryRow("SELECT * FROM users WHERE id = $1", id).Scan(&user)
    if err == sql.ErrNoRows {
        return nil, &DBError{Op: "query", Table: "users", Err: ErrNotFound}
    }
    if err != nil {
        return nil, &DBError{Op: "query", Table: "users", Err: err}
    }
    return &user, nil
}

// Caller can check for specific conditions
user, err := store.GetUser(42)
if errors.Is(err, ErrNotFound) {
    // handle not found
}
var dbErr *DBError
if errors.As(err, &dbErr) {
    log.Printf("database error on table %s: %v", dbErr.Table, dbErr.Err)
}

The String() vs Error() Distinction

type Status struct {
    Code int
    Err  error
}

// String() is for fmt.Println, %v, %s โ€” human-readable display
func (s *Status) String() string {
    return fmt.Sprintf("Status(%d)", s.Code)
}

// Error() satisfies the error interface โ€” used in error handling
func (s *Status) Error() string {
    return fmt.Sprintf("status %d: %v", s.Code, s.Err)
}

s := &Status{Code: 10, Err: errors.New("abc error")}
fmt.Println(s)        // calls String(): "Status(10)"
fmt.Println(s.Error()) // calls Error(): "status 10: abc error"

// As an error:
var err error = s
fmt.Println(err)      // calls Error(): "status 10: abc error"

Error Codes Pattern (gRPC-style)

type Code int

const (
    CodeOK           Code = 0
    CodeNotFound     Code = 1
    CodeUnauthorized Code = 2
    CodeBadRequest   Code = 3
    CodeInternal     Code = 4
)

func (c Code) String() string {
    switch c {
    case CodeOK:           return "OK"
    case CodeNotFound:     return "NOT_FOUND"
    case CodeUnauthorized: return "UNAUTHORIZED"
    case CodeBadRequest:   return "BAD_REQUEST"
    case CodeInternal:     return "INTERNAL"
    default:               return fmt.Sprintf("Code(%d)", int(c))
    }
}

type StatusError struct {
    Code    Code
    Message string
    Details map[string]any
}

func (e *StatusError) Error() string {
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

// Helper constructors
func NotFound(msg string) *StatusError {
    return &StatusError{Code: CodeNotFound, Message: msg}
}

func Internal(msg string, details map[string]any) *StatusError {
    return &StatusError{Code: CodeInternal, Message: msg, Details: details}
}

Resources

Comments