The problem with string errors
You are parsing a configuration file. The parser crashes because the file is missing. You catch the error and print it. The user sees open config.yaml: no such file or directory. That is fine. Now imagine the parser succeeds reading the file but finds a syntax error on line 42. Or the file is valid YAML but contains a negative number where a port should be. If you return a plain string error for every case, the caller has to parse the error message text to figure out what went wrong. That breaks down fast. You need a way to carry structured data with the error so the caller can inspect it programmatically.
Errors are values
In Go, an error is just an interface. The error interface has one method: Error() string. Any type that implements that method is an error. This means you can define a struct with whatever fields you want, add the Error method, and pass it around like any other error. Think of a standard error as a sticky note with a message. A custom error is a sealed envelope. The outside has the message for logging, but the inside holds the details you need to handle the situation.
Errors are values. Treat them like data.
Minimal custom error
Here is the skeleton of a custom error type: a struct with fields and an Error method.
// CustomError holds structured data about a failure.
type CustomError struct {
Code int // HTTP status code for API responses
Message string // Human-readable description
}
// Error implements the error interface.
func (e CustomError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}
func main() {
// Return a custom error from a function.
err := doWork()
if err != nil {
fmt.Println(err)
}
}
func doWork() error {
return CustomError{Code: 404, Message: "resource not found"}
}
When you define CustomError, the compiler checks if it has an Error() string method. If it does, CustomError satisfies the error interface implicitly. You do not write implements error. Go figures it out. At runtime, doWork returns an instance of CustomError. The caller checks err != nil. Since the value is not nil, the block runs. fmt.Println(err) calls the Error method automatically because fmt knows how to print anything that satisfies fmt.Stringer or error. The output shows the formatted string.
Run gofmt on the file. It standardizes indentation and spacing. The community expects this formatting. Do not argue about layout; let the tool decide.
Realistic validation example
Here is a realistic validation function that returns a custom error so the caller can inspect the specific field.
// ValidationError tracks which field failed and why.
type ValidationError struct {
Field string // Name of the field that failed
Reason string // Explanation of the failure
}
// Error returns the formatted validation message.
func (e ValidationError) Error() string {
return fmt.Sprintf("invalid field %q: %s", e.Field, e.Reason)
}
// ValidateUser checks user data and returns a ValidationError if needed.
func ValidateUser(name, email string) error {
if name == "" {
return ValidationError{Field: "name", Reason: "cannot be empty"}
}
if !strings.Contains(email, "@") {
return ValidationError{Field: "email", Reason: "missing @ symbol"}
}
return nil
}
The caller needs to extract the field name and reason. Type assertion is the direct way. The code checks if the error is a ValidationError and extracts the value.
err := ValidateUser("Alice", "alice-no-at-sign.com")
if err != nil {
// Type assertion to access custom fields.
if vErr, ok := err.(ValidationError); ok {
log.Printf("Field %s failed: %s", vErr.Field, vErr.Reason)
}
}
Type assertion panics if the type does not match. Use the comma-ok idiom to check safely. The ok variable tells you if the assertion succeeded.
Convention: Receiver naming. Use short names like e or c. Do not use this or self. The community expects one or two letters matching the type.
Convention: Public fields. If the caller needs the data, export the field. Lowercase fields are private to the package. The caller cannot read them. Public names start with a capital letter.
Convention: Accept interfaces, return structs. Your function returns ValidationError, but the signature says error. The caller accepts the interface. This keeps the API flexible.
Inspecting wrapped errors
Wrapping preserves the cause. fmt.Errorf with %w creates a wrapped error. The wrapper adds context but keeps the original error accessible. errors.Unwrap retrieves the cause. errors.Is and errors.As traverse the chain. This allows you to build error trees. The top level has context. The leaves have the root cause.
Type assertion does not unwrap. Use errors.As when you expect errors to be wrapped by other functions.
// Wrap the validation error with context.
func createUser(name, email string) error {
if err := ValidateUser(name, email); err != nil {
return fmt.Errorf("creating user: %w", err)
}
return nil
}
func main() {
err := createUser("Alice", "bad-email")
var vErr ValidationError
if errors.As(err, &vErr) {
// vErr is populated even though the error was wrapped.
log.Printf("Validation failed on %s: %s", vErr.Field, vErr.Reason)
}
}
errors.As handles wrapped errors. It unwraps the chain until it finds a match. Type assertion only checks the immediate type. Use errors.As for robust error handling in libraries.
The pattern if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors. Return them or handle them immediately.
Pitfalls and compiler errors
Receiver mismatches break builds. If you define func (e *CustomError) Error() string, the method belongs to the pointer type, not the struct. Returning CustomError{...} fails. The compiler rejects this with CustomError does not implement error (method Error has pointer receiver). Fix: change the receiver to (e CustomError) or return &CustomError{...}.
errors.Is requires an Is method. Standard errors work with errors.Is because they are compared by identity or wrapped. Custom structs need an Is(target error) bool method. Without it, errors.Is(err, &CustomError{}) returns false because it compares pointers. Implement Is to support semantic comparison.
Unexported fields hide data. If you define code int, the caller cannot access it. Export the field or provide a method. The worst error bug is the one that never logs because the caller cannot read the details.
The compiler catches receiver mismatches. Runtime bugs hide in unexported fields.
Decision matrix
Use fmt.Errorf when you need a quick error message with no structured data. Use a custom struct when the caller needs to inspect fields like error codes, missing fields, or retry hints. Use errors.New when you need a static sentinel error that can be compared with ==. Use a custom error with an Is method when you want errors.Is to match your error type without pointer comparison issues.
Start simple. Add structure only when the caller needs it.