Go errors are not for validation
Stop stuffing expected problems into error returns. There's a better way.
A Go error means something unexpected happened. The database is down. The network timed out. A nil pointer slipped through. You log it, return 500, and investigate.
But “email is already taken” is not unexpected. Neither is “password too short” or “plan limit reached.” These are expected problems. Your code anticipated them. You wrote the if statement for them. They’re not errors, they’re outcomes.
So why do most Go codebases treat them the same way?
The usual pattern
You’ve seen this a hundred times:
func CreateAccount(email string) (*Account, error) {
if email == "" {
return nil, fmt.Errorf("email is required")
}
// ...
if duplicate {
return nil, fmt.Errorf("email already exists")
}
return acc, nil
}
The caller gets an error. Now what? String matching?
acc, err := CreateAccount(email)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
// return 409
} else if strings.Contains(err.Error(), "required") {
// return 400
} else {
// return 500? maybe?
}
}
This is fragile. You’re parsing English sentences to decide what HTTP status code to return. If someone changes the error message, your handler breaks silently.
Sentinel errors don’t fix it
The next attempt is usually sentinel errors:
var ErrEmailRequired = errors.New("email is required")
var ErrEmailDuplicate = errors.New("email already exists")
var ErrPasswordTooShort = errors.New("password too short")
var ErrPlanLimitReached = errors.New("plan limit reached")
// ... 40 more of these
Now you have a wall of var Err... declarations and the handler does errors.Is() chains. Better than string matching, but you still can’t carry metadata. Which field failed? What was the limit? What should the frontend display?
And you’ve lost the distinction between “email is taken” (expected, tell the user) and “database connection failed” (unexpected, page the on-call).
Separate the expected from the unexpected
Here’s what we do instead. Two return channels with different meanings:
func CreateAccount(ctx context.Context, email string) (*Account, *validation.Result, error) {
*validation.Result, expected problems. Validation failures, business rules, constraint violations. The user or developer can fix these.error, unexpected problems. Bugs, infrastructure failures, things you didn’t anticipate. These get logged and investigated.
The handler never has to guess:
acc, vr, err := repo.CreateAccount(ctx, email)
if err != nil {
// unexpected, log it, return 500
log.Err(err).Msg("could not create account")
writeError(w, "internal_error", 500)
return
}
if !vr.Ok() {
// expected, tell the user what's wrong
writeValidationError(w, vr)
return
}
No string matching. No sentinel errors. No guessing.
The Result type
A Result is just a list of issues. Zero issues means everything is fine.
type Issue struct {
Field string `json:"field,omitempty"`
Code string `json:"code"`
Message string `json:"message,omitempty"`
DevMessage string `json:"devMessage,omitempty"`
Extra any `json:"extra,omitempty"`
}
type Result struct {
Issues []Issue `json:"issues,omitempty"`
Status int `json:"-"`
}
Each issue has:
- Code: machine-readable, never changes:
"required","duplicate","plan_limit_reached" - Field: optional, for input validation:
"email","password" - Message: user-facing, safe to display in UI
- DevMessage: developer-facing, for debugging
- Extra: arbitrary metadata:
{"maxLength": 50},{"currentCount": 10, "limit": 10}
Status carries a suggested HTTP status code but never gets serialized. The API layer uses it, but it doesn’t leak into the response.
Building results
For field-level validation:
validation.NewIssue("email", "required", "email is required")
For non-field problems, chain what you need:
validation.New("plan_limit").
WithMessage("You've reached the app limit for your plan").
WithExtra(map[string]int{"current": 10, "limit": 10}).
WithStatus(http.StatusForbidden)
For duplicates in the repo layer:
if validation.LooksLikeUniqueViolation(err) {
vr := validation.NewIssue("email", "duplicate", "email is already in use")
vr.Status = http.StatusConflict
return vr, nil // not an error, an expected outcome
}
return nil, err // this IS an error, something unexpected
The repo returns the constraint violation as a Result (expected) but the actual database failure as an error (unexpected). The distinction is clear at every layer.
What this fixes
The frontend gets structured data. Instead of parsing {"error": "email already exists"}, it gets:
{
"error": "validation",
"issues": [{
"field": "email",
"code": "duplicate",
"message": "email is already in use"
}]
}
The frontend can highlight the right field, show the right message, and the code never changes even if you reword the message.
The handler never guesses. err != nil is always “log this and return 500.” !vr.Ok() is always “tell the user.” No ambiguity.
Go’s compiler catches missing cases. When you change a function from returning error to returning (*Result, error), every caller that doesn’t handle the new return value fails to compile. Try getting that safety from sentinel errors.
It scales to business rules. This isn’t just for “email is required.” Plan limits, feature gates, rate limits, permission checks, anything where the outcome is expected and the user or developer can act on it.
The rule
If the caller can fix it, return a Result. If nobody expected it, return an error.
That’s it. Simple as that.
← More from the blog