Golang Project Structure

Tips and tricks for writing and structuring Go code

How to Handle Errors

Language

  • unknown

by

Even the best-written code will occasionally run into problems. If hardware breaks, network connections are lost or users provide unexpected input, errors make us aware of the problem and allow us to take action to fix it.

Broken glass in a window as a symbol of errors
Like glass, technology can break. But we should make sure that as little harm as possible is caused when these eventual breakages occur.

We will begin by discussing how errors are defined in Go and then we will go through some examples of working with error-handling techniques.

What Is an Error?

Errors reflect an abnormal, problematic or unexpected state occurring in a program’s runtime.

In Go, errors are generally specified using the standard interface below:

type error interface {
    Error() string
}

In other words, any type that has an Error method that returns a string, giving a human-readable description of the problem, will fulfil the requirements of the error interface.

How to Create Custom Types to Act as Errors

Given what we’ve said above about the simplicity of the native error interface, it’s easy to create your own type that implements it.

The example below shows a custom error being defined and then passed to the builtin panic function:

package main

type printerError struct{}

func (p printerError) Error() string {
	return "cannot access printing device"
}

func main() {
	panic(printerError{})
}

This is a very simple example, since the Error method always returns the same message. In real-world code, the printerError struct would perhaps contain more fields that determine the exact type of problem that has occurred with the computer’s printing device and the string returned by the Error method would differ depending on what went wrong.

What Is the Panic Function?

In some programming languages (such as Java, for example), we talk of “throwing exceptions”. We don’t use that terminology in Go, but calling the panic function with a type that implements the error interface will cause the program to terminate in much the same way.

It is also possible to call the panic function with a string directly (or any value that can be converted by the compiler to a string), which will act as the error message.

Running the code in the example we looked at above, for example, produces this output (using the stderr stream on Linux):

panic: cannot access printing device

goroutine 1 [running]:
main.main()
	/tmp/sandbox2991161662/prog.go:10 +0x27

Program exited.

You can see that the output includes the custom message we defined. However, it also includes information about the line number within our Go file where the panic function was called (the tenth line down) and the number identifying the goroutine (the first — and, in this case, only — one).

The program immediately terminates whenever a call to panic is encountered, so any code that follows the call will not run.

However, functions that have been registered with the defer keyword will run as the program exits. This can be seen in the example below:

package main

import "fmt"

func helperFunction() {
	defer fmt.Println("this will print [1]")

	panic("stop")

	fmt.Println("this won't print [1]")
}

func main() {
	defer fmt.Println("this will print [2]")

	helperFunction()

	fmt.Println("this won't print [2]")
}

The code in the two defer statements will run before the helperFunction and main functions return, allowing the program to terminate.

This can be seen in the program’s output:

this will print [1]
this will print [2]
panic: stop

goroutine 1 [running]:
main.helperFunction()
	/tmp/sandbox3562109774/prog.go:8 +0x73
main.main()
	/tmp/sandbox3562109774/prog.go:16 +0x70

Program exited.

Notice how defer functions always get called in an order opposite to the one in which they’re declared.

How to Create Custom Errors Using the Standard Library

If you ever want to create a custom type that implements the error interface and only ever returns the same string in its Error method, as we did with the printerError that we defined earlier, then the Go standard library has an easier way to do this.

The errors.New function takes a single string, that will act as the error message, and returns a value that will comply with the error interface. Note that if you call the errors.New function twice with the same string, it will return distinct values, although both will contain the same message.

Taking this newfound knowledge into consideration, we could have written the previous example much more simply like so:

package main

import "errors"

func main() {
	panic(errors.New("cannot access printing device"))
}

The fmt.Errorf function works in a similar way, returning a custom error value, while allowing you to use a format specifier (identical to the one used in the fmt.Printf function) in order to create the error-message string.

Here is an example of this function being used to generate an error:

package main

import "fmt"

func main() {
	const userAge = 19
	const minAge = 21

	if userAge < minAge {
		panic(fmt.Errorf("the user's age (%d) is below %d, so access has been denied", userAge, minAge))
	}

	fmt.Println("access granted")
}

You can also see above how conditional code is used to check if a certain restricted or dangerous circumstance has occurred before we call the panic function. This is usually how we would write real-world code, since we should only ever want to stop the program if it’s absolutely necessary.

If you ever find yourself calling panic outside of a conditional block, there may be something fundamentally wrote with your code — unless you are doing it temporarily simply for the purposes of testing your error-handling.

We can see in the output produced by the example above that the two numbers, userAge and minAge, have been correctly interpolated into the error message:

panic: the user's age (19) is below 21, so access has been denied

goroutine 1 [running]:
main.main()
	/tmp/sandbox2479504225/prog.go:10 +0x75

Program exited.

Returning Errors From a Function

Of course, as mentioned earlier, calling panic should be considered a very extreme way to deal with an error, since it stops the entire program and ensures that no other work will be done.

Generally, if an error can be handled or corrected in some way, this is the approach that should be taken. Only if an error is mission-critical should it need to cause the entire program to come to a sudden halt.

If you’re writing a function that performs a specific job, it is good practice to return an error (in addition to as any other return values), so that the user who calls the function can decide for themselves how they want to handle it. This is true even if you’re going to be only person who calls the function, since you may not always want to call it in the same way.

Look at the following example function below:

package main

import "errors"

var (
	validUserIds = []int{4, 9, 32, 91, 194, 201}
)

func userIsValid(userId int) (bool, error) {
	if userId < 0 {
		return false, errors.New("userId cannot be negative")
	}

	for _, validUserId := range validUserIds {
		if userId == validUserId {
			return true, nil
		}
	}

	return false, nil
}

It takes an ID number to determine if there is a valid user in the system associated with that ID. The function will iterate through all of the valid user IDs, and if it finds a match, it will return true, otherwise false.

However, if the ID passed as an argument to the function is a negative number, the function will also return an error value, since it’s assumed that all IDs must be positive.

We could simply have returned false in this case, but by returning an error, we are allowing the person who calls this function to make a choice about how to handle the unexpected input: perhaps another ID could be requested, perhaps the current ID could be multiplied by -1 to make it positive and the function called again or perhaps panic will be called to bring the entire program to a halt.

But the important point is that each of these choices, and others, are potentially valid, so it is important not to restrict other developers’ choices by handing errors within your own functions. Always return the error so that they can decide how best to handle it for themselves.

You will probably have noticed that this is how the Go standard library deals with errors. Look, for example, at the example below:

package main

import (
	"os"
)

func main() {
	file, err := os.Open("/dev/random")
	if err != nil {
		panic(err)
	}

	buffer := make([]byte, 8)
	if _, err = file.Read(buffer); err != nil {
		panic(err)
	}

	fmt.Println(buffer)
}

We are reading from the /dev/random file, which on UNIX-based machines acts as a pseudorandom number generator. It is a special file, because every time we read from it, its contents will appear to contain different pseudorandom bytes.

Both the os.Open and file.Read functions provide an error as well as their primary return value. This allows us to decide how we want to handle any system malfunctions; perhaps, if /dev/random is not available, we might choose to use an alternative source of pseudorandom data, for example, or simply use some non-random data instead.

Since, in the example above, we simply call panic whenever we receive an error, we could create a helper function to deal with the error-handling for us:

package main

import (
	"fmt"
	"os"
)

func checkError(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	file, err := os.Open("/dev/random")
	checkError(err)

	buffer := make([]byte, 8)
	_, err = file.Read(buffer)
	checkError(err)

	fmt.Println(buffer)
}

The checkError function removes a lot of the repetitive boilerplate from our code. However, we should try not to rely heavily on such functions, since we are giving up the advantage of being able to choose to handle different errors differently in different situations.

Leave a Reply

Your email address will not be published.