Skip to main content

Custom Error Types

When simple string errors aren't enough, struct-based custom error types let you include rich information in errors. This covers custom errors, sentinel errors, and error code patterns commonly used in production.

Struct-Based Custom Errors​

Implementing the Error() string method satisfies the error interface.

package main

import (
"errors"
"fmt"
)

// Basic custom error
type AppError struct {
Code int
Message string
Err error // cause error (optional)
}

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 β€” allows errors.Is/As to traverse the chain
func (e *AppError) Unwrap() error {
return e.Err
}

// Helper constructor
func NewAppError(code int, msg string, cause error) *AppError {
return &AppError{Code: code, Message: msg, Err: cause}
}

var ErrDBFailed = errors.New("database operation failed")

func fetchData(id int) (string, error) {
if id <= 0 {
return "", &AppError{Code: 400, Message: "invalid id"}
}
if id > 1000 {
return "", &AppError{
Code: 500,
Message: "fetch failed",
Err: ErrDBFailed,
}
}
return fmt.Sprintf("data-%d", id), nil
}

func main() {
// Success
data, err := fetchData(42)
if err == nil {
fmt.Println("data:", data)
}

// Invalid input
_, err = fetchData(-1)
if err != nil {
fmt.Println("error:", err) // [400] invalid id

var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("code: %d, message: %s\n", appErr.Code, appErr.Message)
}
}

// DB failure (with wrapping)
_, err = fetchData(9999)
if err != nil {
fmt.Println("error:", err) // [500] fetch failed: database operation failed

var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("code: %d\n", appErr.Code)
}
// Can traverse cause error via Unwrap
fmt.Println("DB failed?", errors.Is(err, ErrDBFailed)) // true
}
}

Sentinel Error Pattern​

Pre-defined error values at the package level, used together with errors.Is.

package main

import (
"errors"
"fmt"
)

// Package-level sentinel errors β€” Err prefix convention
var (
// General sentinel errors
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrBadRequest = errors.New("bad request")
ErrTimeout = errors.New("timeout")
ErrCanceled = errors.New("canceled")
)

// Domain-specific sentinel errors
var (
ErrUserNotFound = errors.New("user not found")
ErrUserSuspended = errors.New("user suspended")
ErrEmailDuplicate = errors.New("email already registered")
ErrPasswordInvalid = errors.New("invalid password")
)

// Simple user store
type UserStore struct {
users map[string]string // email β†’ password
}

func NewUserStore() *UserStore {
return &UserStore{users: map[string]string{
"alice@example.com": "secret123",
}}
}

func (s *UserStore) Register(email, password string) error {
if email == "" || password == "" {
return fmt.Errorf("register: %w", ErrBadRequest)
}
if _, exists := s.users[email]; exists {
return fmt.Errorf("register %q: %w", email, ErrEmailDuplicate)
}
s.users[email] = password
return nil
}

func (s *UserStore) Login(email, password string) error {
pwd, exists := s.users[email]
if !exists {
return fmt.Errorf("login %q: %w", email, ErrUserNotFound)
}
if pwd != password {
return fmt.Errorf("login %q: %w", email, ErrPasswordInvalid)
}
return nil
}

func main() {
store := NewUserStore()

// Successful registration
if err := store.Register("bob@example.com", "pass456"); err != nil {
fmt.Println("register failed:", err)
} else {
fmt.Println("register success: bob@example.com")
}

// Duplicate email
if err := store.Register("alice@example.com", "newpass"); err != nil {
fmt.Println("register failed:", err)
if errors.Is(err, ErrEmailDuplicate) {
fmt.Println("β†’ email already in use")
}
}

// Login attempts
attempts := []struct{ email, pass string }{
{"alice@example.com", "secret123"},
{"alice@example.com", "wrongpass"},
{"nobody@example.com", "pass"},
}

for _, a := range attempts {
err := store.Login(a.email, a.pass)
if err == nil {
fmt.Printf("login success: %s\n", a.email)
continue
}
switch {
case errors.Is(err, ErrUserNotFound):
fmt.Printf("login failed (%s): user not found\n", a.email)
case errors.Is(err, ErrPasswordInvalid):
fmt.Printf("login failed (%s): wrong password\n", a.email)
default:
fmt.Printf("login failed (%s): %v\n", a.email, err)
}
}
}

Error Code Pattern​

Error code-based pattern commonly used in HTTP APIs or gRPC services.

package main

import (
"errors"
"fmt"
)

// Error code type
type ErrorCode int

const (
CodeOK ErrorCode = 0
CodeBadRequest ErrorCode = 400
CodeUnauthorized ErrorCode = 401
CodeForbidden ErrorCode = 403
CodeNotFound ErrorCode = 404
CodeConflict ErrorCode = 409
CodeInternal ErrorCode = 500
)

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

// Code-based error type
type CodedError struct {
Code ErrorCode
Message string
Detail string
cause error
}

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

func (e *CodedError) Unwrap() error { return e.cause }

// Convenience constructors
func NotFound(resource, id string) *CodedError {
return &CodedError{
Code: CodeNotFound,
Message: fmt.Sprintf("%s not found", resource),
Detail: fmt.Sprintf("id=%s", id),
}
}

func Unauthorized(reason string) *CodedError {
return &CodedError{
Code: CodeUnauthorized,
Message: "authentication required",
Detail: reason,
}
}

func BadRequest(msg string) *CodedError {
return &CodedError{
Code: CodeBadRequest,
Message: "invalid request",
Detail: msg,
}
}

func Internal(cause error) *CodedError {
return &CodedError{
Code: CodeInternal,
Message: "internal server error",
cause: cause,
}
}

// Helper to extract error code
func GetCode(err error) ErrorCode {
var coded *CodedError
if errors.As(err, &coded) {
return coded.Code
}
return CodeInternal
}

// Example service
func getProduct(id string) (string, error) {
if id == "" {
return "", fmt.Errorf("getProduct: %w", BadRequest("id is required"))
}
if id == "999" {
return "", fmt.Errorf("getProduct: %w", NotFound("product", id))
}
return fmt.Sprintf("Product-%s", id), nil
}

func main() {
testIDs := []string{"", "42", "999"}

for _, id := range testIDs {
product, err := getProduct(id)
if err != nil {
code := GetCode(err)
fmt.Printf("error [%d %s]: %v\n", int(code), code, err)

var coded *CodedError
if errors.As(err, &coded) {
fmt.Printf(" β†’ code: %d, message: %s, detail: %s\n",
int(coded.Code), coded.Message, coded.Detail)
}
} else {
fmt.Printf("product: %s\n", product)
}
}
}

Type-Based Error Hierarchy​

Pattern for building an error hierarchy in complex applications.

package main

import (
"errors"
"fmt"
)

// Top-level error interface (optionally extended)
type DomainError interface {
error
Domain() string // domain where error occurred
Temporary() bool // retryable or not
}

// Base domain error implementation
type baseDomainError struct {
domain string
msg string
temp bool
cause error
}

func (e *baseDomainError) Error() string {
if e.cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.domain, e.msg, e.cause)
}
return fmt.Sprintf("[%s] %s", e.domain, e.msg)
}

func (e *baseDomainError) Domain() string { return e.domain }
func (e *baseDomainError) Temporary() bool { return e.temp }
func (e *baseDomainError) Unwrap() error { return e.cause }

// Domain-specific concrete error types
type AuthError struct {
baseDomainError
UserID string
}

func NewAuthError(msg, userID string, cause error) *AuthError {
return &AuthError{
baseDomainError: baseDomainError{
domain: "auth",
msg: msg,
temp: false,
cause: cause,
},
UserID: userID,
}
}

type NetworkError struct {
baseDomainError
URL string
}

func NewNetworkError(msg, url string, temporary bool, cause error) *NetworkError {
return &NetworkError{
baseDomainError: baseDomainError{
domain: "network",
msg: msg,
temp: temporary,
cause: cause,
},
URL: url,
}
}

// Handler function
func processRequest(userID, url string) error {
if userID == "" {
return NewAuthError("user not authenticated", userID, nil)
}
if url == "bad-host" {
return fmt.Errorf("processRequest: %w",
NewNetworkError("connection refused", url, true, nil))
}
return nil
}

func main() {
cases := []struct{ user, url string }{
{"alice", "api.example.com"},
{"", "api.example.com"},
{"bob", "bad-host"},
}

for _, c := range cases {
err := processRequest(c.user, c.url)
if err != nil {
fmt.Printf("error: %v\n", err)

// Type-based handling
var authErr *AuthError
var netErr *NetworkError

switch {
case errors.As(err, &authErr):
fmt.Printf(" β†’ auth error, user: %q\n", authErr.UserID)
case errors.As(err, &netErr):
fmt.Printf(" β†’ network error, URL: %q, retryable: %v\n",
netErr.URL, netErr.Temporary())
}
} else {
fmt.Printf("success: user=%q url=%q\n", c.user, c.url)
}
}
}

Key Summary

  • Implementing Error() string satisfies the error interface
  • Implement Unwrap() error to enable errors.Is/As chain traversal
  • Sentinel errors use Err prefix, declared at package level
  • Error code pattern: map to HTTP API or gRPC response codes
  • Use hierarchical error types to structure complex domains
  • Use errors.As to extract concrete types and access detailed information