1. Overview

Golang’s error handling has been a topic of much discussion. When I first started working with Golang, I read some documentation about error handling, but I didn’t really care about it. After using Golang for a while, I felt that I might not be able to ignore this issue. So, this article is mainly to organize some common error handling tips and principles in Golang.

2. Techniques and principles of error handling

2.1 Using wrappers to avoid repetitive error judgments

The most common line of code in a Golang project is definitely if err ! = nil. Golang takes errors as return values, so you have to deal with them. But sometimes, error handling judgments can take up half of your code, which makes it look messy. There is an example of this in the official blog.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

Yes, you read that right, it’s actually a 3-line call to fd.Write, but you have to write 9 error judgments. So the official blog also gives a more elegant solution: wrap io.Writer in another layer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

Now it looks much better, the write(buf []byte) method determines the error value internally, to avoid multiple errors outside. Of course, this way of writing may have its drawbacks, for example, you have no way to know which line the error is called on. In most cases, you just need to check for errors and then handle them. The Golang standard library has many similar tricks. For example

1
2
3
4
5
6
7
8
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
    return b.Flush()
}

Where b.Write is returned with an error value, this is just to comply with the io.Writer interface. You can do it again when you call b.Flush() for the error value.

2.2 Error handling before Golang 1.13

Checking for errors

In most cases, we just need to make a simple determination of the error. Because we don’t need to do anything else with the error, we just need to make sure that the code logic is executed correctly.

1
2
3
if err != nil {
    // something went wrong
}

However, there are times when we need to handle the error differently depending on the type of error, for example, if the error is caused by a network connection not connected/disconnected, we should perform a reconnection operation when we determine that it is a network not connected/network disconnected. When it comes to the determination of the error type, we usually have two methods.

  1. compare the error with a known value

    1
    2
    3
    4
    5
    
    var ErrNotFound = errors.New("not found")
    
    if err == ErrNotFound {
        // something wasn't found
    }
    
  2. Determine the specific type of error

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    type NotFoundError struct {
        Name string
    }
    
    func (e *NotFoundError) Error() string { return e.Name + ": not found" }
    
    if e, ok := err.(*NotFoundError); ok {
        // e.Name wasn't found
    }
    

Adding information

When an error returns up the call stack after multiple levels, we usually add some additional information to that error to help the developer determine where the program was running and what happened when the error occurred. The easiest way to do this is to construct a new error using the information from the previous error.

1
2
3
if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

Using fmt.Errorf only keeps the text of the previous error and discards all other information. If we want to keep all the information from the previous error, we can use the following.

1
2
3
4
5
6
7
8
type QueryError struct {
    Query string
    Err   error
}

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

2.3 Error Handling in Golang 1.13

In Golang 1.13, if an error contains another error, the underlying error can be returned by implementing the Unwrap() method. If e1.Unwrap() returns e2, we can say that e1 contains e2.

Using Is and As to check for errors

After mentioning common ways of handling error messages in 2.2, several methods were added to the standard library in Golang 1.13 to help us do the above more quickly. The current prerequisite is that your custom Error correctly implements the Unwrap() method

errors.Is is used to compare an error to a value.

1
2
3
4
5
// Similar to:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

errors.As is used to determine if an error is of a specific type.

1
2
3
4
5
6
// Similar to:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
    // err is a *QueryError, and e is set to the error's value
}

When manipulating a wrapped error, Is and As consider all the errors in the error chain. A complete example is as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type ErrorA struct {
    Msg string
}

func (e *ErrorA) Error() string {
    return e.Msg
}

type ErrorB struct {
    Msg string
    Err *ErrorA
}

func (e *ErrorB) Error() string {
    return e.Msg + e.Err.Msg
}

func (e *ErrorB) Unwrap() error {
    return e.Err
}

func main() {
    a := &ErrorA{"error a"}

    b := &ErrorB{"error b", a}

    if errors.Is(b, a) {
        log.Println("error b is a")
    }

    var tmpa *ErrorA
    if errors.As(b, &tmpa) {
        log.Println("error b as ErrorA")
    }
}

The output is as follows.

1
2
error b is a
error b as ErrorA

Wrapping errors with %w

The %w was added in Go 1.13. Errors returned by fmt.Errorf when %w occurs will have the Unwrap method, which returns the value corresponding to %w. The following is a simple example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type ErrorA struct {
    Msg string
}

func (e *ErrorA) Error() string {
    return e.Msg
}

func main() {
    a := &ErrorA{"error a"}

    b := fmt.Errorf("new error: %w", a)

    if errors.Is(b, a) {
        fmt.Println("error b is a")
    }

    var tmpa *ErrorA
    if errors.As(b, &tmpa) {
        fmt.Println("error b as ErrorA")
    }
}

The output is as follows.

1
2
error b is a
error b as ErrorA

Whether to wrap the error

When you add additional contextual information to an error, either by using fmt.Errorf or by implementing a custom error type, it is up to you to decide whether this new error should wrap the original error information. This is a question that has no standard answer, it depends on the context in which the new error is created.

The purpose of wrapping an error is to expose it to the caller. This allows the caller to handle the error differently depending on the original error, for example os.Open(file) will return a specific error like file does not exist, so that the caller can create the file to allow the code to execute correctly down the line.

If you don’t want to expose implementation details, don’t wrap the error. Because exposing an error with details means that the caller is coupled to our code. This also violates the principle of abstraction.