Reducing the number of error handling “if” statements (if != nil)

Reading Time: 4 minutes

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!

0 Shares:
You May Also Like