Getting started with Go: Error Handling
Error handling is an essential aspect of software development, and Go is no exception. In this article of the series, I am going to show you an overview on Go’s fairly unique approach to handling errors.
In Go, errors are represented by the error
type, which is a built-in interface with a single method: Error() string
. This method returns a string describing the error. It is important to note, that you to use the nil
value to represent the absence of an error.
Now let’s see an example of how you can create an error:
func div(x, y int) (int, error) {
if y == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return x / y, nil
}
Here the div
function takes two integers and returns their quotient. If the second argument is zero, the function returns an error with the message "cannot divide by zero". Otherwise, it returns the quotient and a nil
error, which indicates that no error occurred.
To create custom errors, you can use the errors.New
function on the top of the package, so the error is not created each time the function is called:
import (
"errors"
)
errDivideByZero := errors.New("cannot divide by zero")
func div(x, y int) (int, error) {
if y == 0 {
return 0, errDivideByZero
}
return x / y, nil
}
To check for errors in Go, you can use the if
statement with the err
variable. Look at an example of how you can use the div
function:
result, err := div(10, 2)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
It’s also common to see error handling in Go using the defer
keyword, which allows you to defer the execution of a function until the surrounding function returns:
f, err := os.Open("file.txt")
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
In the example above, the Open
function is called to open the file "file.txt". If there's an error, it is printed to the screen and the function returns. Otherwise, the file is opened and the Close
function is deferred until the surrounding function returns. This ensures that the file is always closed, even if there's an error.
There are also several other ways to handle errors in Go, such as using the panic
and recover
functions to handle runtime errors. As in most cases, panic should only be used in main.go
, handling errors gracefully is the recommended technique.
Sometimes, you may want to wrap an error with additional context before returning it. You can do this using the fmt.Errorf
function, which allows you to create a new error with a formatted string. For example:
_, err := os.Stat("file.txt")
if os.IsNotExist(err) {
return fmt.Errorf("file not found: %w", err)
}
In this short snippet, the Stat
function is used to check if the file "file.txt" exists. If the file doesn't exist, the IsNotExist
function returns true
and a new error is returned with the message "file not found" and the original error as the cause.
In some cases, you may want to return an error from a function if it is unable to complete its task due to an error that it encountered while calling another method. You can do this using the errors.Wrap
function, which allows you to wrap an error with additional context:
_, err := os.Open("file.txt")
if err != nil {
return errors.Wrap(err, "failed to open file")
}
The example above can be useful for providing more context when debugging an error.
You can make your own custom error types with additional methods or fields, by creating a new type that implements the error
interface. For example:
type DivisionByZeroError struct {
message string
}
func (e *DivisionByZeroError) Error() string {
return e.message
}
func div(x, y int) (int, error) {
if y == 0 {
return 0, &DivisionByZeroError{"division by zero"}
}
return x / y, nil
}
Here the DivisionByZeroError
type is defined with a field message
and a method Error
that returns the error message. The div
function returns a DivisionByZeroError
if the second argument is zero, otherwise it returns the quotient and a nil
error.
Type switches can be useful for handling errors that have additional information or custom behavior beyond the basic error
interface. They allow you to handle different types of errors differently and take appropriate action based on the specific error
type:
func handleErrors(err error) {
switch v := err.(type) {
case MyError:
fmt.Printf("MyError: %v\n", v)
case *MyError:
fmt.Printf("*MyError: %v\n", v)
default:
fmt.Printf("Other error: %v\n", v)
}
}
Here the handleError()
function takes an error
value as an argument and uses a type switch to handle different types of errors. The MyError
type is a custom error type that implements the error
interface. The type switch handles both the concrete MyError
type and the pointer *MyError
type. If the error value is not of either of these types, it is handled by the default
case.
Pros vs Cons
In Go, the use of the error
type as a first-class value allows for more flexible error handling than languages that rely on exceptions: the error handling model takes the “plan for failure, not success” approach. Additionally, the lack of exceptions can make it easier to reason about the behavior of a Go app, as it is clear which errors are expected and how they are being handled.
However, there is another side of the coin when it comes to how error handling is implemented in Go. As mentioned above, Go does not have exceptions, so errors must be explicitly checked and handled. This can lead to repetitive error-checking, which can make the code less maintainable. What is more, the error
type is an interface, so it requires the use of type assertions or type switches to access the underlying error value. This can be quite verbose.
That’s A Wrap
Go’s unique error handling model is highly debated in the engineering community, but in my oppinion, the explicit error handling nature and traiting errors first is a reliable way for more robust applications.
In the next article of the Getting started with Go series, we’ll explore the concurrency topic.
You can find more about Evendyne here.