GoLang’s approach to error handling.
GoLang’s way of handling errors is different from any other language I have seen. If anything, it feels like an opinionated way of handling errors in C. Go is very different from Java, Python, JavaScript, or any other language that implements exceptions.
This is probably why people love to criticize error handling in Go. Not having exceptions might feel like an unnecessary limitation.
Go might be simpler than any other mainstream language, but that does not mean it will always be easy and trivial to write valid, idiomatic code. Without exceptions, there is no lazy escape hatch in the form of throw Exception("failed")
.
There are countless books and blog posts about errors in Go. This post is a dump of my notes on the subject. If you find the information here lacking, you can always check out Go’s official blog post, Go’s official tutorial, or this comprehensive blog post on Medium. I also highly recommend the book Learning Go. The chapter about error handling is stellar.
Return the error, no exceptions#
Without exceptions, error handling has to follow the same path as the nominal logic. Instead of throwing and catching exceptions, the user has to return the value and/or the error. The simplest example would be the following:
func div(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("denominator cannot be zero: %f", b)
}
return a / b, nil
}
Rule number one in Go is to have code that is easy to read and reason about. Having a single control flow instead of 2 is a big simplifier. I can’t count how many times I caught exceptions I didn’t expect or didn’t catch them where I was supposed to. Go doesn’t allow unused variables, so errors must be handled or at least not outright ignored. If you don’t use the err
variable returned here: f, err := os.Open(name)
, you get a compilation error. It forces developers to think about errors close to the place where they occur. With exceptions, it is different because errors might be ignored and mishandled a few abstraction layers higher.
One might say that this is bad for code readability because error handling might obfuscate the “happy path”. I think such views miss the point - error handling is actually most of the code that we write. Not only in Go, but in general. There is usually one way the logic can go right and endless ways for the program to go wrong.
error is an interface#
And a very simple one:
type error interface {
Error() string
}
That is it. If your type implements the Error() string
method, it is an error
type.
You can always build custom errors like:
type MyErr struct {
Codes []int
}
func (m MyErr) Error() string {
return fmt.Sprintf("codes: %v", m.Codes)
}
Wrapping and Unwrap()#
Usually, a function returns an error because some other error occurred within it. By wrapping the returned error with the original one, we create an error chain that will be useful to track the original source of the issue. If you don’t return a custom error type and simply want to add a label to the original error, you can use fmt.Errorf
with %w
.
func openFile(name string) error {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("cannot open file: %w", err)
}
defer f.Close()
return nil
}
%w
is a special case documented in the official docs:
If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand.
If you are using a custom error type, this is where the magic ends. Custom error types have to implement the Unwrap
method in order to be chained. The Unwrap
method has to return the original error
like in the following example:
type ErrWrap struct {
err error
}
func (e ErrWrap) Error() string {
return fmt.Sprintf("ErrWrap error: %s", e.err)
}
func (e ErrWrap) Unwrap() error {
return e.err
}
The Unwrap
and error chaining are not always necessary. Sometimes returning a simple error is enough. Implementing the Unwrap
method is required to use errors.Is
and errors.As
. Or rather, to use Wrapped errors, you must also know how and when to use errors.As
and errors.Is
.
errors.Is
#
errors.Is
is the answer to the problem that error wrapping introduces. In the example below, the error from os.Open
is wrapped, making it impossible to further examine it with ==
such as err == os.ErrNotExist
.
func openFile(name string) error {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("cannot open file: %w", err)
}
defer f.Close()
return nil
}
The errors.Is
function allows you to check if a given error is in the error chain.
t.Run("openFile", func(t *testing.T) {
err := openFile("non-existent-file")
// the error is wrapped, so it is not os.ErrNotExist
assert.False(t, err == os.ErrNotExist)
// however, we can check if the error os.ErrNotExist is wrapped
assert.True(t, errors.Is(err, os.ErrNotExist))
})
For more complex error types, refer to the documentation of errors.Is
:
The tree consists of err itself, followed by the errors obtained by repeatedly calling its Unwrap() error or Unwrap() []error method. When err wraps multiple errors, Is examines err followed by a depth-first traversal of its children.
When calling errors.Is
, the Unwrap
method is used to recursively check errors within the chain. You can wrap multiple errors at once by using %w
multiple times or by returning []error
in the Unwrap
method for the custom type. This sounds like a corner case, though, and I want to focus on the simple error chain case. Going further with the documentation:
An error is considered to match a target if it is equal to that target or if it implements a method Is(error) bool such that Is(target) returns true.
A bug is lurking here. For custom error types, we might have to implement the Is(target error) bool
method. If we don’t do this when necessary, errors.Is
might not detect errors in the chain. It is quite a pernicious design, but Is
and As
were added in Go 1.13, so it was rather an afterthought. Anyway, errors.Is
An error is considered to match a target if it is equal. This means ==
is used. For some errors, that is alright. For example:
type ErrWrap struct {
err error
}
func (e ErrWrap) Error() string {
return fmt.Sprintf("ErrWrap error: %s", e.err)
}
func (e ErrWrap) Unwrap() error {
return e.err
}
func TestWrapping(t *testing.T) {
t.Run("Unwrap", func(t *testing.T) {
baseErr := errors.New("base error")
err := ErrWrap{err: baseErr}
wrapped := fmt.Errorf("wrapped: %w", err)
assert.True(t, errors.Is(err, baseErr))
assert.True(t, errors.Is(wrapped, err))
assert.True(t, errors.Is(wrapped, baseErr))
})
}
errors.Is
works as expected here. Both baseErr
and err
are detected in the chain for wrapped
error. But let’s make the ErrWrap
type a little more complex by adding a slice field to the struct.
type ErrWrap struct {
codes []int
err error
}
Without any code changes, the test will fail for the following assertion: assert.True(t, errors.Is(wrapped, err))
. Why isn’t err
(which is the ErrWrap
) detected to be in the chain, but the baseErr
that is the sentinel error is detected correctly? That is because ErrWrap
now consists of a []int
slice that is not comparable. This can be expressed as the assertion: assert.False(t, reflect.TypeOf(err).Comparable())
. So if the custom error type is not comparable, we must write our own Is method.
In our case, we have to do a type assertion and compare the slices with reflect as follows:
func (e ErrWrap) Is(target error) bool {
if err, ok := target.(ErrWrap); ok {
return reflect.DeepEqual(e.codes, err.codes)
}
return false
}
This fixes the failing tests. If you are new to type assertions, you can check them out on this playground.
errors.As
#
errors.As
traverses through the error tree/chain and checks if a given error is of a given type. If so, it assigns the error to a target pointer. It unwraps the error chain and checks each error to see if it can be assigned to the target type. It’s useful when you want to inspect the details of a specific type of error.
Following the ErrWrap
error type from the section above, we can quickly test it:
t.Run("errors.As", func(t *testing.T) {
baseErr := errors.New("base error")
err := ErrWrap{err: baseErr, codes: []int{1, 2, 3}}
wrapped := fmt.Errorf("wrapped: %w", err)
var errWrap ErrWrap
assert.True(t, errors.As(wrapped, &errWrap))
assert.Equal(t, []int{1, 2, 3}, errWrap.codes)
})
If you understand how Is
works, As
is pretty straightforward. A few words of caution, though:
As panics if target is not a non-nil pointer to either a type that implements error or to any interface type.
So the second argument to errors.As
must be a non-nil pointer to either a type that implements error or to any interface type. If you pass anything other than a pointer to an error or a pointer to an interface, the method will panic.
Conclusion#
Most of the error handling in Go revolves around error wrapping. If you understand Unwrap
, Is
, and As
, you are set. While constantly typing if err != nil
might feel daunting, having a single logic flow makes the Go code much easier to reason with. Error wrapping has been added since 1.13
, and it is a bit awkward, but good enough to follow the idiomatic approach. I skipped panics
to keep this read short, but if you are interested in panic/recover
, which is essentially the exceptions in Go, refer to this doc.