Many programming languages base their error handling around exceptions. Some of them use exceptions when handling abnormal situations, while others use exceptions as a normal redirection in a control flow. For example, an object in Python throws the StopIteration exception when the iterator is exhausted. Go has a rather unique way of handling errors that is very different from the traditional focus on exceptions.
In this article, I’ll take a closer look at Go errors and describe how handling them differs from dealing with this issue in other programming languages.
What are Golang errors?
Before we discuss how Golang errors are different from exceptions, let’s talk briefly about what they are. Errors in Golang are just values, nothing less and nothing more. Like all the regular values they can be assigned to a variable and passed between functions. There is only one catch: the value must implement the error interface. The error interface has only one method: Error, that takes no arguments and returns a string. So as long as an object implements this method it is good to Go!
Error interface in Go:
type error interface {
Error() string
}
The interface approach has two main advantages:
- Simplicity - It highlights that an error is just a value that can return a message about what went wrong.
- Flexibility - Go only expects the object to implement the error method. You can easily replace errors in your project at any time with little to no headache.
More about error utilities
As I said, errors at their base level are objects that contain messages about what went wrong, but they can be much more than that.
One of the most powerful aspects of errors is that they can be wrapped. Wrapping is an act of creating a new error that embeds another error and adds contexts to it. This behavior may be recurrent, creating an easy to read chain of errors. The picture below contains a visual representation of an error wrapped three times.
When printed, the error message is created by adding all of the wraps from top to bottom. So in our case it would look like this:
wrap #3 wrap #2 wrap #1 error
What Golang errors are not
With the knowledge of what Go errors are, we can talk about what they are not. As you now already know, they are not exceptions and probably that’s for the best. Why would we consider missing resources, exhausted iterators or incorrect user inputs as something exceptional when these are everyday scenarios. When writing code in Go we do not expect, try, or throw exceptions when something might go wrong. We just check for the error and decide how to move on. For rare situations when we cannot handle an error, a panic mechanism is used. This will be described in the next article in this series.
Here, you can check our Golang development services.
Error libraries in the Golang language
Go has a simple to use, built-in, standard library called errors that goes hand in hand with the fmt library. It only has four methods:
- New(text string) error – new is straightforward, it creates a new error with a given message.
- Unwrap(err error) error – unwraps an error.
- Is(err, target error) bool – reports if any errors present in the error chain matches the target.
- As(err error, target any) boo – searches for the first error in the error chain that matches the target, and if one is found, sets the target to that error value and returns true. Otherwise, it returns false.
Three of these methods use the wrap mechanic of the errors, but the library does not provide a way to wrap any errors. Luckily we can wrap errors using the fmt library. In order to do it we need to call the Errorf function, use the %w verb to format the error message of a wrap and pass err as an argument. (This functionality has been supported since Go 1.13)
An example of wrapping errors using the fmt library is presented in the code below.
func readAndPrint(fileName string) error {
data, err := readFile(fileName)
if err != nil {
return fmt.Errorf("can't print data: %w", err) #3
}
os.Stdout.Write(data)
return nil
}
func readFile(fileName string) ([]byte, error) {
file, err := os.ReadFile(fileName) #1
if err != nil {
return nil, fmt.Errorf("can't open file: %w", err) #2
}
return file, nil
}
func main() {
err := readAndPrint("motto.txt") #4
if err != nil {
log.Fatal(err)
}
fmt.Println("<--------------------------------->")
err = readAndPrint("hello_world.txt") #5
if err != nil {
log.Fatal(err)
}
}
This simple program prints the contents of a file. It has some extra nested functions just to underline how to wrap errors. Let’s take a look at what is going on inside it.
#1 - This is the only line that can go wrong in our program. We call the function ReadFile from the OS package that along the []byte (bytes read from the file) returns an error.
#2 - When reading a failed file, we wrap the error with more context about it.
#3 - In this error check, we are aware that we wanted to read a file in order to print it, but since the reading file has failed we add more context to the error.
#4 - We call our readAndPrint function to read motto.txt. This file exists in the same directory as main.go.
#5 - This time we read a file which does not exist in this directory.
Output of the program:
We do what we must, because we can
<--------------------------------->
2022/11/25 07:58:40 can't print data: can't open file: open hello_world.txt: no such file or directory
exit status 1
The program prints the content of motto.txt as expected, and right after that comes a log with an error. We can see our two wraps on top of an error returned by ReadFile.
How to use error handling during programming
There are two cases of error usage that I’ll describe:
- Calling a function that returns an error
- Creating a new error
Let’s start with a simpler scenario when an error appears in your code – calling a function that returns an error.
Consider the following function signature:
func divide(a, b float64) (float64, error)
By just looking at the name we expected it to divide into two numbers. We also know that something might go wrong inside the function, because along with the result it returns an error. And this is how you can use it:
a, b := 10.0, 0.0
result, err := divide(a, b) #1
if err != nil { #2
fmt.Println("failed to divide", err) #3
log.Fatal(err)
}
print(result)
#1 - We initialize all the variables that the function returns. It is advised to keep errors in the err variable.
#2 - Now comes the important part. Before we use any of the values that the function returned, we check the error. If the error exists then something went wrong inside the function. The error should contain information about what went wrong.
#3 - Inside the if statement we usually wrap the error with some message and return it. In this example our function call is located inside the main function, so we print the error and use log.Fatal to stop the program.
It is also important to remember that we shouldn’t use the result when an error occurs even though it was initialized after the function call. When a function returns an error, the rest of its output is no longer reliable and in most cases is equal to a zero-value.
Output of the program:
failed to divide I cannot divide by 0 :(
2022/11/07 13:29:45 I cannot divide by 0 :(
exit status 1
Notice that the function did return 0 as the result. This 0 is the zero-value of float type, and it does not mean that ten divided by zero is equal to zero.
Let’s take a look at how you can create an error in our code by recreating the divide function used before.
func divide(a, b float64) float64 {
return a / b
}
I started from scratch - a simple function that returns the result of dividing a by b. So far so good, but our code is dangerous. When I pass b=0 to our function the program will panic. Once we recognize the danger in our function we can add an error to the signature of our function and handle it gracefully.
func divide(a, b float64) (float64, error) { #1
if b == 0 { #2
return 0, errors.New("I cannot divide by 0 :(") #3
}
return a / b, nil #4
}
#1 - Error is added to the function signature.
#2 - We check for the troublesome value of b. If it is zero, we do not divide.
#3 - We return the zero-value as the result and create a new error with information about what went wrong.
#4 - Now, with dividing by zero out of the way we can return the expected result and nil as an error.
Zen of Go errors
After the first contact with Go many developers notice the ongoing procedure of checking the error after almost every function call. A function only returns an error when something can go wrong during its work, but we are in the programming world - everything here can go wrong. It results with now-iconic if err !=nil statements all over our code.
The error check is an extra three lines of code, but this constant checking leads to an increase in overall awareness during the programming. In your code you do not try, you just do it and check if it succeeded, and if it didn’t you add more context to the error by wrapping it and then propagating the error higher in the calls hierarchy.
Well-wrapped errors carry crucial information about the cause and its journey through the system improving overall workflow and debugging.
Summary
In this article we have just scratched the surface of the very important topic that is errors in Go. We hope you found it useful and we recommend checking out our other articles in which we dive deeper into the world of errors!
If you’re interested in Go-related topics, check out our other articles about: