The Secret Life of Go: Error Handling (Part 2)
Source: Dev.to
Data-Rich Errors, Custom Structs, and errors.As Eleanor is a senior software engineer. Ethan is her junior colleague. They work in a beautiful beaux arts library in Lower Manhattan — the kind of place where coding languages are discussed like poetry. Episode 31 Ethan was building a user registration endpoint. He had learned his lesson from the previous day and was dutifully avoiding string matching. “I have a problem with Sentinel errors,” Ethan said, turning his monitor toward Eleanor. “They are great for simple states like ErrNotFound. But what if the error is a validation failure? I need to tell the frontend exactly which field failed and why. I can’t write a Sentinel variable for every possible bad email address.” He showed her his workaround: // Ethan’s attempt to return data func validateUser(u User) (string, string, error) { if !strings.Contains(u.Email, ”@”) { return “email”, “missing @ symbol”, errors.New(“validation failed”) } return "", "", nil }
Eleanor winced slightly. “You are returning the error details separately from the error itself. That breaks the standard Go function signature. What if this error needs to bubble up through three different functions before the HTTP handler catches it? Do they all have to return (string, string, error)?” “That’s exactly why I’m stuck,” Ethan admitted. “An error in Go is just a string interface. How do I put structured data inside it?” “An error in Go is not just a string,” Eleanor corrected gently. “It is an interface with a single method: Error() string. That means any struct can become an error, as long as it implements that method.” She opened a new file and defined a custom struct: // ValidationError holds structured data about what went wrong. type ValidationError struct { Field string Reason string Err error // The underlying wrapped error (optional, but good practice) }
// Implement the error interface func (v *ValidationError) Error() string { return fmt.Sprintf(“validation failed on %s: %s”, v.Field, v.Reason) }
// Implement the Unwrap interface so errors.Is and errors.As can look inside it func (v *ValidationError) Unwrap() error { return v.Err }
“Now,” Eleanor said, “your validation function can just return a standard error, but underneath, it is secretly passing a rich data structure.” func validateUser(u User) error { if !strings.Contains(u.Email, ”@”) { // We return a pointer to our custom struct return &ValidationError{ Field: “email”, Reason: “missing @ symbol”, } } return nil }
errors.As)
Ethan looked at the calling code in his HTTP handler. “Okay, so validateUser returns an error interface. How do I get my Field and Reason back out of it? errors.Is just returns a boolean.” “If errors.Is is for checking identity, errors.As is for extracting data,” Eleanor explained. She wrote the extraction logic in his HTTP handler: err := validateUser(newUser) if err != nil { // 1. Create an empty pointer of your custom error type var valErr *ValidationError
// 2. Ask Go: "Is there a *ValidationError anywhere inside this error chain?
// If so, unpack its data into my valErr variable."
if errors.As(err, &valErr) {
// 3. We now have fully typed access to the struct's fields!
response := map[string]string{
"error": "invalid input",
"field": valErr.Field,
"reason": valErr.Reason,
}
return sendJSON(w, 400, response)
}
// Fallback for unexpected errors
return sendJSON(w, 500, "internal server error")
}
Ethan stared at the errors.As(err, &valErr) line. “It’s like a type assertion, but better. If I used a standard type assertion like err.(*ValidationError), it would fail if the error had been wrapped with %w somewhere along the way.” “Exactly,” Eleanor smiled. “Because of the Matryoshka doll wrapping we learned yesterday, a simple type assertion only looks at the outermost doll. errors.As opens up every single doll in the chain until it finds a struct that matches the type of valErr. If it finds one, it populates your variable and returns true.” Ethan leaned back, looking at his clean, structured HTTP handler. “This is a game-changer. I can pass HTTP status codes, retry hints, and validation fields right inside the error itself.” “You can,” Eleanor agreed. “But remember the distinction. Use Sentinel Errors (with errors.Is) for state—answering the question ‘What happened?’. Use Custom Error Structs (with errors.As) for data—answering the question ‘What are the details?’.” Ethan deleted his messy (string, string, error) return values. The Go error interface wasn’t a limitation anymore; it was a carrier pigeon, perfectly capable of delivering whatever payload he needed, safely and strictly typed. The error Interface In Go, an error is any type that implements Error() string. By creating a custom struct and attaching this method, you can build errors that carry rich, structured data (like HTTP status codes or validation fields). The Unwrap Method If your custom error struct wraps another error, you should implement Unwrap() error. This allows Go’s standard library functions to look “inside” your struct when searching the error chain. errors.As vs. Type Assertions A standard type assertion err.(*MyError) will fail if the error was wrapped (e.g., using fmt.Errorf(”… %w”, err)). errors.As(err, &myErr) traverses the entire error chain, unwrapping each layer. If it finds a type match anywhere in the chain, it populates your target variable and returns true. The Architect’s Rule of Error Handling Identity: Use Sentinel variables and errors.Is when you only need to know what happened (e.g., ErrNotFound). Data: Use Custom Structs and errors.As when you need to know the details of what happened (e.g., ValidationError{Field: “email”}). Aaron Rose is a software engineer and technology writer at tech-reader.blog. For explainer videos and
podcasts, check out Tech-Reader YouTube channel.