When I started learning Go, my code usually ended up looking like this:
func GetDataFromFile(fileUri string) (*Data, error) {
file, err := os.Open(fileUri)
if err != nil{
return nil, err
}
fileBytes, err := ioutil.ReadAll(file)
if err != nil{
return nil, err
}
data := &Data{}
err = json.Unmarshal(fileBytes, &data)
if err != nil{
return nil, err
}
_, err = strconv.Atoi(data.Index)
if err != nil{
return nil, err
}
err = ValidateMessageLength(data)
if err != nil{
return nil, err
}
return data, nil
}
This is the way error handling is usually managed in Go. It is very common that a function returns a result and an error, so we can check if there was actually an error before using the result.
At that time, this kind of error handling looked a little bit annoying for a Java programmer like me, so I looked for options to handle errors in Go.
Convention return values
An option I considered was returning a convention value instead of an error. For example, returning an empty array, map or a struct could mean that something went wrong. But in that case, you would need an if statement to check for that, right?. So this is not the solution I was looking for.
Defer and recover
One of the things I used very frequently as a Java programmer was the try-catch blocks. By trying to emulate that behavior, I did something like this:
func CallingFunction() {
defer func() {
if r := recover(); r != nil {
fmt.Println("There was an error:", r)
}
}()
data := GetDataFromFile("hello.json")
fmt.Println(data.Message)
}
func GetDataFromFile(fileUri string) *Data {
file, err := os.Open(fileUri)
panicIfError(err)
fileBytes, err := ioutil.ReadAll(file)
panicIfError(err)
data := &Data{}
err = json.Unmarshal(fileBytes, &data)
panicIfError(err)
_, err = strconv.Atoi(data.Index)
panicIfError(err)
err = ValidateMessageLength(data)
panicIfError(err)
return data
}
func panicIfError(err error) {
if err != nil {
panic(err)
}
}
Very simple. When an error is found, panic
is called immediately. This causes the execution of that function to stop. Then, when the function returns, we use defer
and recover
to handle the error.
This approach is a little bit risky though. The function calling to GetDataFromFile
must be prepared in case a panic
is raised.
It is also imposible to return something based on the result of the function called by defer
because it is executed before the function returns.
TryCatch function
So I started looking for a better solution. It didn't take me too much time to find a post in the golang-nuts forum about this concern.
It was in one of the replies that I found a link to a very interesting solution. There I met the TryCatch function which share some coincidences compared to my own approach. These are some of the most important parts of the function:
type tryFunc func(...interface{})
func tryCatch(try func(tryFunc), catch func(error) bool) error {
err := error(nil)
done := make(chan struct{})
...
go func() {
...
defer func() {
...
done <- struct{}{}
}()
try(func(v ...interface{}) {
if e, ok := v[len(v)-1].(error); !ok || catch(e) {
return
} else {
err = e
panic(sig)
}
})
}()
<-done
return err
}
Here we can see that the try
function checks for errors and, if the catch
is unable to handle them, a panic is raised which is then handled by the function pushed by the defer
when the function returns. The panic takes a signal struct{}{}
to ensure the tryCatch function won't recover for any other event but the inability of the catch function to handle the error.
These functions shares some similarities with my first approach like the use of defer
and recover
, but it encapsulates all the logic inside a function.
In my opinion, the problem I had with this function is it adds a little bit of clutter to the code.
For a more complete example of how the function works, check this example.
Small functions
One way I found to reduce the number of if
blocks for error handling is simply to make small functions. In my case, I prefer to have a maximum number of 2 if
error handling statements in my functions, so I would split the first example as follows:
func GetDataFromFile(fileUri string) (*Data, error) {
fileBytes, err := getFileContent(fileUri)
if err != nil {
return nil, err
}
data, err := unmarshalData(fileBytes)
if err != nil {
return nil, err
}
err = validateData(data)
if err != nil {
return nil, err
}
return data, nil
}
func getFileContent(fileUri string) ([]byte, error) {
file, err := os.Open(fileUri)
if err != nil {
return nil, err
}
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
return nil, err
}
return fileBytes, err
}
....
Now I've reduced the number of if
statements in the GetDataFromFile
function by keeping the function small but, we can still get rid of some if
statements.
Remove unnecessary if statements
Now that we separated our function into smaller functions, we can notice that, inside some of the error handling if
statements, we return the same values that the container function returns (like in the getFileContent
function). If that is true for the last if
statement, then we can get rid of it as follows:
func GetDataFromFile(fileUri string) (*Data, error) {
fileBytes, err := getFileContent(fileUri)
if err != nil {
return nil, err
}
data, err := unmarshalData(fileBytes)
if err != nil {
return nil, err
}
err = validateData(data)
return data, err
}
func getFileContent(fileUri string) ([]byte, error) {
file, err := os.Open(fileUri)
if err != nil {
return nil, err
}
return ioutil.ReadAll(file)
}
func unmarshalData(fileBytes []byte) (*Data, error) {
data := &Data{}
err := json.Unmarshal(fileBytes, &data)
return data, err
}
func validateData(data *Data) error {
_, err := strconv.Atoi(data.Index)
if err != nil {
return err
}
return ValidateMessageLength(data)
}
Now we can see that we have two if
blocks in the GetDataFromFile
function (but that's fine for me) and at most one in the other functions.
The GetDataFromFile
function passed from having five error handling if
statements to only two.
Take advantage of it
Handling errors this way offers an advantage: error customization and, in fact, I think is the reason why it was made this way).
For example, we can help to improve the debugging process by adding extra information to the error we are returning e.g.
fileBytes, err := getFileContent(fileUri)
if err != nil {
return nil, errors.New("Error getting file bytes: "+err.Error())
}
That will help us know in which process our program failed.
Conclusion
There are several ways to reduce the number of error handling if
statements or even to get rid of them at all like in the case of the tryCatch function, but I feel is better to go the "Go" way.
We should try to use these pieces of code to return customized errors that could add useful extra information that will be helpful when debugging our code.
And of course, keeping our functions small not only will help to reduce the number of error handling if
blocks, but also to make our code more readable and maintainable, but well, the last is out of the scope of this post.
Thanks for reading!