20.7 Custom Error Types: Adding Structured Information
Right, let’s talk about making your errors actually useful. The built-in error interface is brilliantly simple, but let’s be honest, a string wrapped in an interface is about as informative as a “Something Went Wrong” alert on a vending machine that just ate your last dollar. You know a problem exists, but you have no idea why or what to do about it. That’s where custom error types come in. We’re going to move beyond “file not found” to “file ‘go.mod’ not found in /home/you/project: permission denied”.
The core idea is simple: since error is just an interface requiring an Error() string method, any type that implements that method is an error. This is your ticket to stuffing your error objects with all the contextual baggage you need to debug issues later.
The Basic Custom Error Type
Let’s start with the simplest form: a struct that holds some extra information. Imagine we’re building a config parser.
type ConfigFileError struct {
FilePath string
Operation string // e.g., "read", "parse", "open"
UnderlyingErr error
}
// Implement the error interface
func (e *ConfigFileError) Error() string {
return fmt.Sprintf("config file error: %s on %s: %v", e.Operation, e.FilePath, e.UnderlyingErr)
}
See what we did there? We’ve created a structured error. Now, when this error bubbles up, its string message is descriptive, and crucially, we can programmatically interrogate it because it’s not just a string.
func loadConfig(path string) error {
file, err := os.Open(path)
if err != nil {
return &ConfigFileError{
FilePath: path,
Operation: "open",
UnderlyingErr: err,
}
}
defer file.Close()
// ... rest of the parsing logic
return nil
}
Unwrapping and The errors.Is/As Combo
The example above introduces a critical concept: wrapping the underlying error. We didn’t just replace the original os.Open error; we encapsulated it. This is the cornerstone of idiomatic Go error handling. It preserves the entire error chain. But how do we check for it? You could use a type assertion, but don’t. That’s the old way. Go 1.13 gave us the errors.Is and errors.As functions, and you should use them religiously.
To make our ConfigFileError work seamlessly with these functions, we need to implement the Unwrap() error method.
func (e *ConfigFileError) Unwrap() error {
return e.UnderlyingErr
}
This one method unlocks everything. Now you can handle errors like a pro:
err := loadConfig("fake.toml")
// Check if it's ANY ConfigFileError
var configErr *ConfigFileError
if errors.As(err, &configErr) {
fmt.Printf("The problematic file was: %s\n", configErr.FilePath)
}
// Check if the error chain ultimately contains a permission error
if errors.Is(err, os.ErrPermission) {
fmt.Println("Ah, you need to run this with the right privileges!")
}
This is incredibly powerful. The calling code can decide how to handle the error. It can handle all ConfigFileError types broadly, or it can drill down to specific underlying errors like os.ErrNotExist or os.ErrPermission without having to know anything about the ConfigFileError type itself. The abstraction is beautiful.
Common Pitfalls and The %w Verb
The biggest pitfall is forgetting to implement Unwrap(), which renders your custom error opaque to errors.Is and errors.As. Don’t be that person.
Another rookie move is manually constructing the wrapped error chain. You almost never need to call Unwrap() yourself. Let the errors package do that heavy lifting. Your job is to set up the chain correctly.
And here’s the best part: for simple cases, you often don’t even need to create a full custom type. The fmt.Errorf function and the %w verb are your best friends for quick wrapping.
if err != nil {
return fmt.Errorf("config file error: open %s: %w", path, err)
}
This creates a simple error that wraps the original err and implements Unwrap() error automatically. It’s perfect for adding context on the fly without defining a new struct. Use it everywhere. But remember, it’s just a string. If you need structured data you can program against, you still need the full custom type.
When to Go Full Custom
So, when is the struct-with-fields approach worth the effort? It’s whenever the caller might need to make a decision based on the structured data inside the error, not just display it.
A HTTP client library might return a custom HTTPError that includes the status code, allowing the caller to decide if a 429 should be retried but a 404 should not. A database layer might return a ConstraintError with the name of the violated constraint. This is how you elevate your error handling from a mere debugging aid to a real part of your API’s contract.