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
}
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
}
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
}
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"
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
}
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)