Image alt

One thing I love about Go is that it gives you so much with so little. For example, the errors package is one of the smallest packages in the standard library and still, it gets you covered like a Swiss knife.

In this article, I want to show you how easy is to handle errors in Go, and hopefully, by the end of it, you’ll feel as empowered as I am when working with them.

All the examples are in this GitHub repository if you want to test them locally, so now let’s dive in!

Simple text errors Link to heading

The easiest way to return an error from a function is to call errors.New.

Bellow, registerUser returns a value of type error by calling errors.New:

package main

import (
	"errors"
	"log"
)

func main() {
	if err := registerUser("Andrei", -1); err != nil {
		log.Fatalf("error registering user: %s", err)
	}
}

func registerUser(name string, age int) error {
	if name == "" {
		return errors.New("name is empty")
	}
	if age <= 0 {
		return errors.New("age is <= 0")
	}
	return nil
}

error in Go is an interface, which means that any type can implement it as long as it matches the interface signature which is just an Error method that returns a string:

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

It’s worth noting that calling errors.New with the same string results in two distinct errors. That’s because the New function returns an address, and no matter what string you put there, it will always return a different one. We can check the standard library for the implementation details:

func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

Error variables Link to heading

Error variables are still constructed with errors.New, but because we store the return in separate variables it allows the caller to check and handle individual errors:

package main

import (
	"errors"
	"fmt"
)

var (
	ErrEmptyName  = errors.New("name is empty")
	ErrInvalidAge = errors.New("age is <= 0")
)

func main() {
	err := registerUser("", 20)
	// here we check for different errors
	if errors.Is(err, ErrEmptyName) {
		fmt.Println("Your name is empty")
	}
	if errors.Is(err, ErrInvalidAge) {
		fmt.Println("Your age is invalid")
	}
}

func registerUser(name string, age int) error {
	if name == "" {
		return ErrEmptyName
	}
	if age <= 0 {
		return ErrInvalidAge
	}
	return nil
}

To check the value of err, we call the errors.Is function which checks if the err is equal to its target.

Remember, errors in Go are values, and you can compare them the same way you would do with any value.

This approach is useful when you want to handle different errors in different ways, similar to how you would treat exceptions in other languages.

Error Types Link to heading

Sometimes error variables are not enough. Maybe you need a more complex type that can have several fields. That’s easily done: just have a type that has the method Error() string signature:

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field string
}

// ValidationError implements error interface due to this method
func (e ValidationError) Error() string {
	return fmt.Sprintf("%s is invalid", e.Field)
}

func main() {
	err := registerUser("", 20)

	// checking that err is of type ValidationError
	if errors.As(err, &ValidationError{}) {
		fmt.Printf("Validation failed: %s\n", err)
	}
}

func registerUser(name string, age int) error {
	if name == "" {
		return ValidationError{Field: "name"}
	}
	if age <= 0 {
		return ValidationError{Field: "age"}
	}
	return nil
}

Above we defined a ValidationError struct that implements the error interface, and we can check for the type using errors.As function.

Remember that to check for a type T, you need to use &T in errors.As for target.

errors.Is vs errors.As Link to heading

They sound similar, so they might confuse you at first, but to recap, remember that errors.Is checks for equality and errors.As checks for type.

For example, I said in the beginning that errors.New will return a different error even if we use the same string. Let’s test that inequality using errors.Is:

package main

import (
	"errors"
	"fmt"
)

func main() {
	errFirst := errors.New("hello")
	errSecond := errors.New("hello")

	if errors.Is(errFirst, errSecond) {
		fmt.Println("they are equal")
	} else {
		fmt.Println("they are not equal")
	}
}

run in Playground

As we can see they are not equal and errors.Is is working as expected.

errors.As will check for type and not only that: if the err satisfies that type it sets the target to that error value:

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("%s is invalid", e.Field)
}

func main() {
	err := registerUser("", 20)

	var errValidation ValidationError
	if errors.As(err, &errValidation) { // will check for type and unpack err to errValidation
		fmt.Println("err is of type ValidationError and has field:", errValidation.Field)
	}
}

func registerUser(name string, age int) error {
	if name == "" {
		return ValidationError{Field: "name"}
	}
	if age <= 0 {
		return ValidationError{Field: "age"}
	}
	return nil
}

When you have a custom error type with several fields, two variables of the same type can have different field values, so checking for equality no longer works. That’s why is useful to have a method to check for an error type.

Wrapped errors Link to heading

If you read the docs of errors.Is and errors.As you will see that it says something like “Is reports whether any error in err’s tree matches target." or “As finds the first error in err’s tree that matches target”.

Earlier, I said that Is check for equality and As checks for type. But that’s not the full picture.

Errors in Go can wrap(contain) other errors. So when you call Is or As for an err against a target, it will not compare only err to target, but also any wrapped error inside of err against target.

This lets you compose errors from other errors and you would still manage to handle them properly.

The simplest way to create an error that wraps another error is by calling fmt.ErrorF and using the %w operand for the error you want to wrap:

package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("%s is invalid", e.Field)
}

func main() {
    err := registerUser("", 20)

    //err is of type ValidationError because it's wrapping it
    if errors.As(err, &ValidationError{}) {
       fmt.Printf("Validation failed: %s\n", err)
    }
}

func registerUser(name string, age int) error {
    err := validate(name, age)
    if err != nil {
       // the error returned by fmt.Errorf will wrap err because we use %w
       return fmt.Errorf("error on validation: %w", err)
    }
    return nil
}

func validate(name string, age int) error {
    if name == "" {
       return ValidationError{Field: "name"}
    }
    if age <= 0 {
       return ValidationError{Field: "age"}
    }
    return nil
}

You can also use %w multiple times thus allowing you to wrap multiple errors:

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := getTwoWrappedErrors()
	if errors.As(err, &FirstError{}) {
		fmt.Println("err is wrapping FirstError")
	}
	if errors.As(err, &SecondError{}) {
		fmt.Println("err is wrapping SecondError")
	}
}

func getTwoWrappedErrors() error {
	var (
		f FirstError
		s SecondError
	)
	return fmt.Errorf("i am wrapping to errors: %w and %w", f, s)
}

type FirstError struct {
}

func (FirstError) Error() string {
	return "i'm custom error"
}

type SecondError struct {
}

func (SecondError) Error() string {
	return "i'm second error"
}

That’s all for now! I hope you can see how much flexibility we have with only 3 functions.

Further reading:

Errors are values

Working with errors in Go 1.13

errors package documentation