Busy Developer Guide to Go: Error handling
This post’s intended audience are software engineers experienced in other programming languages who are interested in either trying out Go or Go or need to learn for a new job.
The idea is to shape the knowledge into bite-sized pieces: easy to ingest and relate to experiences from other programming languages.
In the series
The error handling topic is interesting enough, to keep parts small I decided to split into the parts:
- Error Handling
- Wrapping
How do I try-catch exceptions in Go?
Well, you don’t. In Go, errors are values.
That means, instead of “throwing”, error is a return value just like any other value. For example, to open a file:
f, err := os.Open("config.yml")
The Open
function returns two values: file descriptor and error. On success the error is nil, otherwise it has some value.
if err != nil {
// ... do something
return err
}
Usual pattern of error handling is checking if error is not null. You may tell that such explicitness comes with verbosity. And yes, Golang code is often scattered with if err != nil
.
Oh please, do I have to check if err != nil every time?
Think out of the box :-)
Errors are values. Use all known programming techniques to process them.
A clever example to avoid constantly checking if error is not nil
, may be found in Go’s bufio.Scanner
:
const input = "The quick brown fox jumps over lazy dog."
scanner := bufio.NewScanner(strings.NewReader(input))
scanner.Split(bufio.ScanWords)
count := 0// We're not checking the error, just iterate
for scanner.Scan() {
count++
}// See if there was any error in the end
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "reading input:", err)
}
The scanner.Scan()
returns true if there was a match, and false when not or there was error. Any error that could occur while scanning is memoized. A check is deferred after scanning loop is finished: there is no need to check every iteration.
What actually the “error” is?
It can be anything. Generally, it’s any type that implements simple, single-method error
interface:
type error interface {
Error() string
}
Out of the box, Go provides built-in errors.New
constructor.
errors.New("missing ID parameter")
… or more flexible fmt.Errorf()
:
return fmt.Errorf("Unsupported message type: %q", msgType)
Returning errors
This is pretty easy since Go allows multiple return values. Put error type as last one. If everything went fine, just return nil.
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero prohibited")
}
return a/b, nil
}
Custom error type
In vast majority of cases you are fine with errors that internally are strings. Eventually, they just get logged somewhere.
If you need to make decision based on returned error, don’t try to parse its string value. To put more context into error, you may create your own type.
Imagine you want to return error from HTTP server handling function with status code:
type HttpError struct {
StatusCode int
Err error
}func (h HttpError) Error() string {
return fmt.Sprintf("HTTP error: %v (%d)", h.Err, h.StatusCode)
}
Errors are values, so create and return as any other struct:
func handlerFunc(r http.Request) error {
// ... // something went wrong:
return HttpError{
StatusCode: 404,
Err: errors.New("product not found"),
} // ...}
Then use type assertion to cast general error
to our specific HttpError
:
he, ok := err.(HttpError)
if ok {
log.Printf("HTTP error with status = %d", he.StatusCode)
}
Or use recommended, safer way:
var he HttpError
if errors.As(err, &he) {
log.Printf("HTTP error with status = %d", he.StatusCode)
}
Logging
Hint: handle errors deep in the stack, log at the top.
This approach helps prevent from duplicate logs. Use wrapping technique to add context and information helpful during debugging. Popular package https://pkg.go.dev/github.com/pkg/errors also allows keeping stack trace with the log.
Errors handling is quite broad topic. More on wrapping and how it can help you, in the next article.