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 %ws, 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:

May the err be with you!