Custom Error Types
A sentinel like ErrNotFound answers one question: did this specific failure
happen? But often the caller needs more — which field was invalid, what
value was rejected, which HTTP status to return. A flat string can't hand
that over without the caller parsing text. The fix: make your own error type.
Any struct can be an error
Recall that error is just a one-method interface. So a struct becomes an
error the moment it has an Error() string method — and a struct can carry
fields:
type HTTPError struct {
Status int
URL string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("%s: status %d", e.URL, e.Status)
}
Now the error is the data. Error() produces the human-readable message,
but the Status and URL fields are still there for code to read.
Pointer receiver, return the address
The convention is a pointer receiver — func (e *HTTPError) Error() — and
you return the address of a literal:
func fetch(url string) error {
// ...
return &HTTPError{Status: 404, URL: url}
}
Why the pointer? Two reasons. Comparisons and errors.As work by type, and a
*HTTPError is one clean type to match. And returning a pointer avoids copying
the struct each time the value moves up the stack. Stick with the pointer form
and every error of this kind behaves consistently.
Recovering the type with errors.As
The caller has an error interface value. To get the fields back, use
errors.As — it walks the wrap chain, and if it finds a *HTTPError, copies
it into your variable:
err := fetch("https://example.com/x")
var httpErr *HTTPError
if errors.As(err, &httpErr) {
if httpErr.Status == 404 {
// react to the DATA, not a parsed string
}
}
errors.As succeeds even if some layer wrapped the error with %w first —
same chain-walking as errors.Is, but matching by type and extracting the
value instead of matching by identity.
Typed error vs sentinel
They answer different questions, so pick by what the caller needs:
- Sentinel (
errors.Is) — the caller only needs identity: "was this a not-found?" One shared value, nothing to carry. - Typed error (
errors.As) — the caller needs data: the field name, the status, the offending input. The error is a struct that carries it.
If you ever find yourself formatting details into a message just so the caller
can strings.Contains them back out, that's the signal to reach for a typed
error instead.
Why this design
This is the "errors are values" idea taken to its conclusion: an error isn't a
magic control-flow signal, it's an ordinary value you get to design. Give
it exactly the fields a caller would need to make a decision, write one method
to render it, and the same value serves both the log line and the branching
logic. No exception hierarchy, no catch (SomeException e) — just a struct and
an interface you already understand.
Try it
Define a ValidationError that carries the offending field name, implement its
Error() method, and return it from a small check function — then let the
tests pull it back out with errors.As.
Practice
1. A Validation Error Type
IntermediateThe starter declares a custom error type that carries the field that failed validation, plus a check function:
type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string func checkName(name string) errorImplement two things:
-
Error()on*ValidationError— return the message in exactly this form (note the literal wordfield, the space, the colon, and the space):field name: must not be emptyThat is,
"field " + e.Field + ": " + e.Message. -
checkName— whenname == "", return&ValidationError{Field: "name", Message: "must not be empty"}. Otherwise returnnil.
The point of a typed error is that the caller gets the data back, not just a string. The tests use
errors.Asto pull a*ValidationErrorout of the returnederrorand check that itsFieldis"name"and itsError()matches exactly.checkName("Ada")must returnnil.package main import ( "errors" "fmt" ) // ValidationError is a custom error type. It carries the field that failed // validation and a human-readable message describing the problem. type ValidationError struct { Field string Message string } // Error makes *ValidationError satisfy the error interface. It must return the // message in the exact form: field <Field>: <Message> // e.g. field name: must not be empty func (e *ValidationError) Error() string { // TODO: implement return "" } // checkName returns a *ValidationError when name is empty, otherwise nil. // The returned error must have Field "name" and Message "must not be empty". func checkName(name string) error { // TODO: implement return nil } func main() { err := checkName("") fmt.Println(err) var v *ValidationError if errors.As(err, &v) { fmt.Println("failed field:", v.Field) } fmt.Println(checkName("Ada")) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
-