Own Your Errors! (Hard Truths About Go's Error Handling)
Admitting error clears the score
And proves you wiser than before.– Arthur Guiterman
Now that it's clear that Go will not receive any fancy error handling syntax—no “check
”, no “try()
”, no “?
"—, one part of the Go community cheers in joy while the other part is genuinely disappointed.
I'm here for the disappointed ones. I hear you: the syntax is verbose to the point that error handling sometimes dominates the code on the screen. Yet, error handling design is more than just defining a syntax. When Go came out, it boldly deviated from the mainstream languages’ way of hiding the error handling from the eyes of the developer as much as possible. Instead, Go makes error handling explicit and clearly visible.
And don't forget:
If 80% of your Go code consists of error handling, it is because 80% of your code might fail at any time.
– Preslav Rachev
So here are a few hard truths about error handling in Go.
Hard truth #1: Errors are values
Errors in Go do not represent a special program state. They don't change execution flow under the hood as exceptions do. They are just values of types that implement the error
interface. As such, an error can be: -
- a function's return value (or a part of it)
- a field in a struct
- passed through channels
- collected in a slice
- (you name it)
When a function encounters an erroneous situation, it creates an error and returns it to the caller:
if (divisor == 0) {
return errors.New("division by zero")
}
No hidden execution paths, no silent unwrapping of the function call chain until some exception handler chimes in. The direct caller gets informed of the error and must act upon it.
Hard truth #2: errors is an interface type
The error
type is an interface with one method, Error()
:
type error interface {
Error() string
}
The simplest implementation of an error is errorString
:
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
That's it. No runtime magic, no specialized type. Just an interface, a struct, and a method. No baking, no boiling, no artificial flavors required.
Hard truth #3: You can create custom error types
The interface nature of errors make creating custom errors a breeze. Any custom type can represent an error, as long as it implements the Error() string
method.
Hard truth #4: Errors can be wrapped with context info
When I wrote about an error handling proposal back in February, I shared a story of a low-level error that bubbled up from the depths of an application and left a single message in the log files: “Generic SSA NOTOK”. Without context, it was impossible to trace the error message back to the situation that triggered it.
This is why proper error wrapping is important. Contextual information is gold when tracking down a malfunction.
Go, pragmatic as it is, uses string formatting for wrapping an error:
f, err := os.Open("nonexistent.file")
if err != nil {
return fmt.Errorf("Cannot open nonexistent.file: %w", err)
}
Note the %w
verb that must be matched by an argument that satisfies the error
interface. Errorf()
uses this for wrapping the passed-in error. (It is possible to pass multiple errors to Errorf()
using multiple %w
s, by the way.)
Hard truth #5: Better use errors.Is()/As() to compare errors
Because errors are values, you might be tempted to use the standard comparison operator ==
for comparing an error against another error, or some type assertion to test an error against a given error type, such as:
if err == ErrNotFound { ... }
if e, ok := err.(*FileError); ok { ... }
Beware! These attempts will fail with wrapped errors. They would only test the outermost error in a tree of wrapped errors.
Better use errors.Is()
and errors.As()
.
errors.Is(err, target error)
compares an error to a value, considering all errors in a tree of wrapped errors:
if errors.Is(err, ErrNotFound) { ... }
errors.As(err error, &target any)
tests if the given error, or any error wrapped inside, is of a specific type. It does so by attempting an assignment to target
, which must be a value of that type. If that assignment succeeds, target
is set to the error value, so that the error handling code can access any fields and methods (if it has any).
if f, err := os.Open("nonexistent.file"); err != nil {
var pathError *fs.PathError //
if errors.As(err, &pathError) {
return fmt.Errorf("can't read file at '%s': %w", pathError.path, err)
} else {
return err
}
}
Conclusion: Go's error handling is easy and straightforward, after all
Go's error handling is made of simple ingredients and is quite easy to understand and customize. If you're not convinced yet, or if you are curious to dig deeper, here is some more brain fodder:
- Why Go's Error Handling is Awesome | rauljordan::blog
- Popular Error Handling Techniques in Go - JetBrains Guide
- Go's Error Handling Is a Form of Storytelling · Preslav Rachev
- Go Error Handling Techniques: Exploring Sentinel Errors, Custom Types, and Client-Facing Errors | Arash Taher Blog
- Go’s Error Handling Is Perfect, Actually :: Very Good Software, Not Virus
- Working with Errors in Go 1.13 - The Go Programming Language
- Errors are values - The Go Programming Language
May the err
be with you!