When a Go project grows, sentinel errors multiply. Every package defines its own ErrNotFound, ErrInvalidInput, ErrPermissionDenied. The presentation layer ends up with long switch statements mapping each error to HTTP status codes:

switch {
case errors.Is(err, user.ErrNotFound):
    return http.StatusNotFound
case errors.Is(err, order.ErrNotFound):
    return http.StatusNotFound
case errors.Is(err, product.ErrNotFound):
    return http.StatusNotFound
// ... and so on
}

I wanted a simpler approach: define error categories once and check against them. So I built knownerror.

How it works

Create category errors and extend them:

// Categories (usually in a shared package)
var (
    ErrNotFound  = errors.New("not found")
    ErrForbidden = errors.New("forbidden")
)

// Domain-specific errors extend categories
var ErrUserNotFound = knownerror.New("user not found").Extends(ErrNotFound)
var ErrOrderNotFound = knownerror.New("order not found").Extends(ErrNotFound)

Now a single check handles all “not found” cases:

if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

Attaching root cause

When returning a sentinel error, you often want to preserve the underlying cause for logging:

user, err := db.QueryUser(id)
if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound.WithCause(err)
}

The returned error still matches ErrUserNotFound via errors.Is(), but errors.Unwrap() gives you the original database error.

Multiple categories

An error can extend multiple categories:

var ErrResourceUnavailable = knownerror.New("resource unavailable").
    Extends(ErrNotFound, ErrForbidden)

// Both checks return true:
errors.Is(err, ErrNotFound)  // true
errors.Is(err, ErrForbidden) // true

When to use

Most projects don’t need this. A handful of errors per package is fine to handle individually. Consider this library when you have many sentinel errors across packages and want centralized categorization for presentation layer mapping.

Repository: github.com/pprishchepa/knownerror