Errors

8 min read

Authors
banner

In this tutorial, let's talk about error handling.

Notice how I said errors and not exceptions as there is no exception handling in Go.

Instead, we can just return a built-in error type which is an interface type.

type error interface {
    Error() string
}

We will circle back to this shortly. First, let's try to understand the basics.

So, let's declare a simple Divide function which, as the name suggests, will divide integer a by b.

func Divide(a, b int) int {
	return a/b
}

Great. Now, we want to return an error, let's say, to prevent the division by zero. This brings us to error construction.

Constructing Errors

There are multiple ways to do this, but we will look at the two most common ones.

errors package

The first is by using the New function provided by the errors package.

package main

import "errors"

func main() {}

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("cannot divide by zero")
	}

	return a/b, nil
}

Notice, how we return an error with the result. And if there is no error we simply return nil as it is the zero value of an error because after all, it's an interface.

But how do we handle it? So, for that, let's call the Divide function in our main function.

package main

import (
	"errors"
	"fmt"
)

func main() {
	result, err := Divide(4, 0)

	if err != nil {
		fmt.Println(err)
		// Do something with the error
		return
	}

	fmt.Println(result)
	// Use the result
}

func Divide(a, b int) (int, error) {...}
$ go run main.go
cannot divide by zero

As you can see, we simply check if the error is nil and build our logic accordingly. This is considered quite idiomatic in Go and you will see this being used a lot.

Another way to construct our errors is by using the fmt.Errorf function.

This function is similar to fmt.Sprintf and it lets us format our error. But instead of returning a string, it returns an error.

It is often used to add some context or detail to our errors.

...
func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("cannot divide %d by zero", a)
	}

	return a/b, nil
}

And it should work similarly.

$ go run main.go
cannot divide 4 by zero

Sentinel Errors

Another important technique in Go is defining expected Errors so they can be checked explicitly in other parts of the code. These are sometimes referred to as sentinel errors.

package main

import (
	"errors"
	"fmt"
)

var ErrDivideByZero = errors.New("cannot divide by zero")

func main() {...}

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, ErrDivideByZero
	}

	return a/b, nil
}

In Go, it is considered conventional to prefix the variable with Err. For example, ErrNotFound.

But what's the point?

So, this becomes useful when we need to execute a different branch of code if a certain kind of error is encountered.

For example, now we can check explicitly which error occurred using the errors.Is function.

package main

import (
	"errors"
	"fmt"
)

func main() {
	result, err := Divide(4, 0)

	if err != nil {
		switch {
    case errors.Is(err, ErrDivideByZero):
        fmt.Println(err)
				// Do something with the error
    default:
        fmt.Println("no idea!")
    }

		return
	}

	fmt.Println(result)
	// Use the result
}

func Divide(a, b int) (int, error) {...}
$ go run main.go
cannot divide by zero

Custom Errors

This strategy covers most of the error handling use cases. But sometimes we need additional functionalities such as dynamic values inside of our errors.

Earlier, we saw that error is just an interface. So basically, anything can be an error as long as it implements the Error() method which returns an error message as a string.

So, let's define our custom DivisionError struct which will contain an error code and a message.

package main

import (
	"errors"
	"fmt"
)

type DivisionError struct {
	Code int
	Msg  string
}

func (d DivisionError) Error() string {
	return fmt.Sprintf("code %d: %s", d.Code, d.Msg)
}

func main() {...}

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, DivisionError{
			Code: 2000,
			Msg:  "cannot divide by zero",
		}
	}

	return a/b, nil
}

Here, we will use errors.As instead of errors.Is function to convert the error to the correct type.

func main() {
	result, err := Divide(4, 0)

	if err != nil {
		var divErr DivisionError

		switch {
		case errors.As(err, &divErr):
			fmt.Println(divErr)
			// Do something with the error
		default:
			fmt.Println("no idea!")
		}

		return
	}

	fmt.Println(result)
	// Use the result
}

func Divide(a, b int) (int, error) {...}
$ go run main.go
code 2000: cannot divide by zero

But what's the difference between errors.Is and errors.As?

The difference is that this function checks whether the error has a specific type, unlike the Is function, which examines if it is a particular error object.

We can also use type assertions but it's not preferred.

func main() {
	result, err := Divide(4, 0)

	if e, ok := err.(DivisionError); ok {
		fmt.Println(e.Code, e.Msg) // Output: 2000 cannot divide by zero
		return
	}

	fmt.Println(result)
}

Lastly, I will say that error handling in Go is quite different compared to the traditional try/catch idiom in other languages. But it is very powerful as it encourages the developer to actually handle the error in an explicit way, which improves readability as well.

© 2024 Karan Pratap Singh