DEV Community

Cover image for Error handling in Go
Igor Grieder
Igor Grieder

Posted on

Error handling in Go

Each programming language has it's own way of dealing with errors. Popular languages such as JavaScript, Java, C#, and Python choose to use Exceptions to indicate something went wrong at runtime. Other languages, such as Go and Rust, choose to handle errors as values instead, using another paradigm that at first can be odd when you come from the other side of the force.

In this article, I'll cover up the type of errors we have in Go and how to master handling them.

Types

If you are reading this article, probably you've seen the famous err != nil expression that's often seen while coding in Go (in contrast we have try-catches, that can be messy too). I'll try to convince you that treating errors as values is a good choice after all and we can do better than just always returning them.

In Go, we basically have three types of errors: opaque, sentinels and custom errors. Let's cover them up.

Opaque Errors

Opaque errors do not give us much information apart from "an error happened" and the error itself. We often use this kind of error when the caller does not need to know what kind of error happened in the operation, since it won't take any logic operation based on it.

package main

import (
    "errors"
    "fmt"
)

type user struct {
    name    string
    balance int64
}

func main() {
    // Users mocked
    userFrom := &user{
        name:    "Igor",
        balance: 200,
    }

    userTo := &user{
        name:    "Maria",
        balance: 293,
    }

    err := DecrementBalance(userFrom, userTo, 204)
    if err != nil {
        fmt.Printf("Insuficient funds to decrement balance from: %v", err)
    }

  fmt.Println("Transaction finished")
}

func DecrementBalance(userFrom *user, userTo *user, val int64) error {
    if userFrom.balance < val {
        // Creating an opaque error
        return errors.New("user does not have the necessary balance")
    }

    userFrom.balance -= val
    userFrom.balance += val

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Sentinel errors

Let's increment now our example and let's say we need to know what kind of error happened. We can use what we call of sentinel errors to assert that the error emitted is of the kind we are dealing with.

Sentinel errors are often defined as package level variables. They are useful when you want to check for a specific error condition using errors.Is and branch logic based on it.

package main

import (
    "errors"
    "fmt"
)

// Define the sentinel error
var ErrInsufficientFunds = errors.New("user does not have the necessary balance")

type user struct {
    name    string
    balance int64
}

func main() {
    userFrom := &user{name: "Igor", balance: 200}
    userTo := &user{name: "Maria", balance: -23}

    err := DecrementBalance(userFrom, userTo, 204)
    if err != nil {
        // Check if the error is exactly our sentinel
        if errors.Is(err, ErrInsufficientFunds) {
            fmt.Printf("Transaction failed: %v", err)
            return
        }
        fmt.Println("An unexpected error occurred, try again")
    }
}

func DecrementBalance(userFrom *user, userTo *user, val int64) error {
    if userFrom.balance < val {
        // Return the sentinel
        return ErrInsufficientFunds
    }

    if userTo.balance < 0 {
        // Return opaque error
        return errors.New("negative user balance, operation canceled")
    }

    userFrom.balance -= val
    userTo.balance += val

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Custom Errors

Sometimes a simple string isn't enough. You might want to return dynamic data, like how much money was missing, something that would hold information, not just the actual error. For this, we use Custom Errors. These are structs that implement the error interface by defining an Error() string method.

package main

import (
    "errors"
    "fmt"
)

// Define a custom error struct
type InsufficientFundsError struct {
    Current  int64
    Required int64
}

func (e *InsufficientFundsError) Error() string {
    return fmt.Sprintf("insufficient funds: have %d, need %d", e.Current, e.Required)
}

func main() {
    userFrom := &user{name: "Igor", balance: 200}
    userTo := &user{name: "Maria", balance: 293}

    err := DecrementBalance(userFrom, userTo, 204)
    if err != nil {
        // Use errors.As to unwrap the error into our struct
        var fundsErr *InsufficientFundsError
        if errors.As(err, &fundsErr) {
            fmt.Printf("Transaction failed: you are short %d\n", fundsErr.Required - fundsErr.Current)
                return
        }

        fmt.Printf("An unexpected error occurred: %v\n", err)   
    }
}

func DecrementBalance(userFrom *user, userTo *user, val int64) error {
    if userFrom.balance < val {
        // Return the custom error struct
        return &InsufficientFundsError{
            Current:  userFrom.balance,
            Required: val,
        }
    }
    // some code
  return nil
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Errors

In real applications, errors bubble up through many layers (Repository -> Service -> Handler). If every layer just returns the error, you lose context. You know what went wrong, but not where or why.

To have a kind of a call stack behavior we can use error wrapping with %w. This allows to add context at each layer while still keeping the original error available to use errors.Is and errors.As for business logic, e.g. If it's just wanted to have a better vision of the error, using %v is an option, since it appends the error message to the next error. Overall, it's all about context for debuging and to navigate better in the codebase.

Here is how we can build a "perfect stack" of information that really helps when things go wrong. The mock result for the operation would be:

"transaction failed: transfer failed: insufficient funds"
Enter fullscreen mode Exit fullscreen mode

Offcourse we can increment a log on it, but just by looking at the example we can clearly understand what happened!

func main() {
    // something...
    err := MakeTransaction(userFrom, userTo, 204)
    if err != nil {
        // Print the full chain: "transaction failed: transfer failed: insufficient funds"
        fmt.Printf("Error: %v\n", err)

        // We can still check the root cause!
        if errors.Is(err, ErrInsufficientFunds) {
             fmt.Println(">> Root cause was money.")
        }
    }
}

func MakeTransaction(from, to *user, amount int64) error {
    err := DecrementBalance(from, to, amount)
    if err != nil {
        // Wrap the error with more context
        return fmt.Errorf("transaction failed: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

If you are new to Go or the general idea of errors as values leave a comment if this article helped you to understand it better! Always remember to handle your errors gracefully.

Top comments (0)