Golang : a valid competitor for the crown of error handling

let's venture into the world of Golang and the intricacies of Error handling in it

Golang : a valid competitor for the crown of error handling

Error handling is a crucial aspect of any programming language, and Golang is no exception. In fact, Go's approach to error handling is quite distinctive and designed to be explicit and straightforward. In this blog, we'll delve into the intricacies of error handling in Go, exploring how it differs from other languages and highlighting best practices along the way.


Go's approach to error handling is focused on simplicity and clarity. It follows the philosophy that errors should be treated as values, not exceptions. This approach is very different from Python and Javascript which rely on Exceptions.

In Go, an error is represented as an instance of an interface Error, with the following inner definition.

 type error interface {
    Error() string
}

which encompasses an Error() method. So Any type that implements this method satisfies the error interface and can be treated as an error.

This means that errors in Go are plain values that can be assigned to variables, returned from functions, and passed as arguments just like any other value.

In Go, it's common for functions to return both a result and an error. This double return pattern is often used to indicate success or failure. Such as

func funcName(var1, var2 dataType) (returnType1, error) {
    if /*Condition for failure*/ {
        return 0, errors.New("Error Statement")
    }
    return value, nil
}
/* note : *datatype* represents the primitive datatypes 
as well as structs and interface */

A little Deeper:

Go's Standard library provides Errors package for giving high-level control over the error implementation and customization. The errors.new() is a commonly utilized function, defined as

func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

New returns an error that formats as the given text by instantiating an object of type errorString with given text value

Sentinel Errors:

Now that we've discussed errors.New()let's talk about sentinel errors

Generally, sentinel errors refer to specific predefined error values that are used to represent particular error conditions. These error values act as "sentinels"(Call them markers if you want to) to indicate certain exceptional situations in the code. Sentinel errors are typically constants or global variables.

import (
    "io"
    "os"
)
func readFile() error {
    file, err := os.Open("example.txt")
    if err != nil {
        if err == io.EOF {
            // Handle end of file
        } else {
            // Handle other errors
        }
        return err
    }
    defer file.Close() //closing the file is the last thing that happens

    // space for processing "file" if everything goes to plan
    return nil
}

Here io.EOF is a sentinel Error defined inside the io package as follows

var EOF = errors.New("EOF")

Different packages in a programming language may define their own sentinel errors to represent specific error conditions that are relevant to the functionality of those packages. These sentinel errors are typically constants or variables defined within the package and are used to indicate specific error situations.

For reference "database/sql" pkg provides sql.ErrNoRows sentinel with definition

var ErrNoRows = errors.New("sql: no rows in result set")

Handling Errors with if Statements

In Go, error handling is explicit and typically involves using if statements to check for errors.

result, err := funcName(arg_1,arg_2)
if err != nil {
    fmt.Println("Error:", err)
} else {
    fmt.Println("Result:", result)
}

How is extra information added to errors in Golang?

Answer: Wrapping Errors

As errors propagate through different layers of a Go application, it's often useful to add context or wrap errors to provide more information about what went wrong. The fmt.Errorf function is commonly used for this purpose:

    func HighOrderFunction() error {
    data, err := someFunction()
    if err != nil {
        return fmt.Errorf("Extra Context for previously
        acquired error: %w", err)
    }
    // ... process data
    return nil
}

The %w verb in the fmt.Errorf format string allows you to wrap the original error, preserving its type and message while adding context.

But As errors are wrapped with additional context, the error chain can become more complex. This complexity can make it challenging to understand the exact source and nature of an error and excessive wrapping can lead to the loss of original error details. When multiple layers of wrapping are involved, it becomes difficult to access the original error message or other relevant information.

The shorthand solution

to this is to use errors.Unwrap() for unwrapping the errors, but Manual Unwrapping for a complex Error chain is Avoided for 2 main reasons

  1. Unwrapping errors manually often involves repetitive code patterns. You need to check each error in the chain, potentially with nested if statements, which can make your error-handling code verbose and less readable.

  2. Manually unwrapping errors requires careful attention to detail. Mistakes in the unwrapping process can lead to incorrect error handling or even unexpected program behavior.

So what can be done?

Note: In Go, it is generally considered good practice to keep error wrapping to a minimum.

However, the Errors package does provide 2 very useful functions

errors.Is() && errors.As()

Let us understand their purpose, usage and implementation.

  1. errors.Is()

    • Purpose: It is used for checking if an error (or any of its wrapped errors) matches a specific target error value.

    • Usage: You pass two errors to. It returns true if it finds a match, indicating that the error (or a wrapped error) is of the same type as the target error. It's primarily used for error comparison.

    • Example:

        if errors.Is(err, io.EOF) {
            // here io.EOF is target error
            // code which executes if errors match....
        }
      
  2. errors.As():

    • Purpose: errors.As() is used for type assertion(.As() was named after Assertion), specifically to attempt to extract an error of a particular type from an error chain.

    • Usage: You pass an error and a pointer to a target error type to errors.As() . It returns true if it finds an error of the target type in the chain, and assigns that error to the provided pointer.

    • Example:

        var customErr *CustomError // pointer to custom error type
        if errors.As(err, &customErr) {
      
            // rest of the code handles the customErr
        }
      

To Avoid Confusion between the two, errors.Is() is for checking if an error matches a specific target error, while errors.As() is for attempting to extract an error of a particular type from an error chain.


Is the Error handling in Go Limited only for returning errors explicitly?

NO

In addition to returning errors explicitly, Go provides 3 special control flow mechanisms for handling errors: defer, panic and recover

1. defer

The defer statement has a multitude of Use cases

  • schedule a function call to be executed just before the surrounding function returns.

  • It's often used for cleanup tasks or to ensure that certain actions happen, no matter how the function exits (like through a return or panic).

  • We also use defer to delay the execution of functions that might cause an error.

Fun fact : defer statements are executed in a last-in, first-out (LIFO) order.

This means that if multiple defer statements are used within a function, the one defined last will be executed first.

package main
import "fmt"
func main() {
    //simulating a file being opened
    file := "example.txt"
    defer closeFile(file) // This will be executed last

    performOperations()// Simulate some operations that might cause an error

    defer logCompletion() // This will be executed second-to-last
    // More operations
    fmt.Println("More program execution...")
}
func performOperations() {
    //Imagine an error prone funnction
    fmt.Println("Performing operations...") 
    panic("An error occurred")//Simulation of an error
}
func closeFile(file string) {
    // Simulate closing a file or releasing of resource
    fmt.Printf("Closing file: %s\n", file)
}
func logCompletion() {
    // Simulate logging the completion of a task
    fmt.Println("Task completed.")
}

2. panic

panic is a built-in function in Go that is used to deliberately cause a runtime error.

When a function encounters a panic, it immediately stops executing, and the control flow is moved to deferred recover functions (if any). If no deferred recover function is found, the program will terminate.

func main() {
  //Simple demonstration of panic()
  fmt.Println("Help! Something bad is happening.")
  panic ("Ending the program") //Terminates the program
  fmt.Println("Waiting to execute")//doesn't execute 
}

standalone panic is typically used for unrecoverable errors or situations where it's unsafe to continue executing the program.

3. recover

recover is also a built-in function in Go. main use is to regain control over a panicking goroutine and resume normal execution. recover is only useful when called from a deferred function during a panic.

func main() {
    defer func() { 
        if r := recover(); r != nil { 
            /*deferred recover func*/
            fmt.Println("Recovered from panic:", r)
        }
    }()

    if somethingBadHappened() { /*somethingBadHappend()
is a placeholder for situation considered to be error inducing*/
        panic("Oh no, something bad happened!")
    }
    // Normal execution continues
}

It's important to note that recover is not meant for regular error handling. It is meant for exceptional situations where you need to regain control over the program and potentially perform cleanup or logging


Conclusion

In this exploration of Go's error-handling capabilities, we've explored a language feature that truly sets Go apart. Go's approach to error handling, characterized by its simplicity, clarity, and adherence to strong conventions, might be second only to RUST.

So whether you're building a small utility or a large-scale application, Go's error handling shines as a robust mechanism that'll help you write code that is not only efficient but also maintainable in the long run.

So, if you value code that is clean, clear, and reliable, Go's error handling is yet another compelling reason to embrace this language for your software development projects.

Happy coding with Go!