Emulating monads in Go for better error handling

Leonardo
4 min readJul 18, 2024

--

In Go, error handling traditionally involves checking if an error is nil after each operation that might produce an error. This can lead to a lot of repetitive if err != nil checks throughout your code. While Go does not support monads in the way functional languages like Haskell do, you can approximate monadic behavior using some patterns.

Let’s first look at a conventional example of error handling in Go without using a monadic pattern. This example will involve fetching a string value, converting it to an integer, and then performing some processing on it, with error checks after each step.

Example without Monads

package main

import (
"fmt"
"strconv"
)

func main() {
value, err := fetchValue()
if err != nil {
fmt.Println("Error fetching value:", err)
return
}

intValue, err := parseValue(value)
if err != nil {
fmt.Println("Error parsing value:", err)
return
}

processedValue, err := processValue(intValue)
if err != nil {
fmt.Println("Error processing value:", err)
return
}

fmt.Println("Processed Value:", processedValue)
}

func fetchValue() (string, error) {
// Simulate fetching a value that could fail
return "123", nil
}

func parseValue(value string) (int, error) {
// Simulate parsing the value to an integer
return strconv.Atoi(value)
}

func processValue(value int) (string, error) {
// Further process the value
return fmt.Sprintf("Processed: %d", value), nil
}

Now, let’s implement the same functionality using a monadic pattern. We’ll use a custom struct to encapsulate results and errors and provide a method for chaining operations cleanly.

Example with Monads

package main

import (
"fmt"
"strconv"
)

// Result encapsulates an integer and an error
type Result struct {
Value int
Err error
}

// Bind chains operations on the result if there is no error
func (r Result) Bind(f func(int) Result) Result {
if r.Err != nil {
return Result{Err: r.Err}
}
return f(r.Value)
}

// WrapError is a utility function to create a Result from an error
func WrapError(err error) Result {
return Result{Err: err}
}

func main() {
result := fetchValueMonad().Bind(parseValueMonad).Bind(processValueMonad)
if result.Err != nil {
fmt.Println("Error:", result.Err)
} else {
fmt.Println("Processed Value:", result.Value)
}
}

func fetchValueMonad() Result {
// Simulate fetching a value that could fail
return Result{Value: 123}
}

func parseValueMonad(value int) Result {
// Simulate processing that could fail
return Result{Value: value * 2} // Example processing
}

func processValueMonad(value int) Result {
// Further process the value
return Result{Value: value + 10}
}

Analysis

  • Without Monads: The code explicitly checks for errors after each function call, leading to repetitive if err != nil blocks. This makes the code verbose and harder to read.
  • With Monads: The Result struct and its Bind method encapsulate the error handling, allowing for cleaner chaining of operations. This pattern reduces the need for explicit error checks in the main logic flow, making the code more concise and easier to follow.

In the context of the Go code examples I provided, the “monad” is emulated using the `Result` struct along with its associated `Bind` method. This structure and method together mimic the behavior of a monad, a concept primarily from functional programming languages like Haskell.

Here’s a breakdown of how the Result struct and its operations relate to the concept of a monad:

Components of the Monad-Like Structure

1. Type Constructor: In functional programming, a monad is defined for a type constructor, which in this context is the Result struct. This struct can hold either a value of a certain type (int in the provided example) or an error, encapsulating the state of an operation which could either succeed with a value or fail with an error.

2. Unit Function: Often called return in Haskell, this is a function that takes a value and returns a monad containing that value. In the Go example, this isn’t explicitly defined as a function, but can be seen when we directly construct Result{Value: value}. This acts like wrapping a value into the monadic context.

3. Bind Function (Bind): This is the key function that defines how to process and chain monadic values. The Bind method takes a monadic value (Result) and a function that operates on the contained value and returns another monad. The `Bind` function checks if there’s an error in the current monad; if not, it applies the function to the contained value, otherwise, it propagates the error. This method handles the chaining and propagation of errors seamlessly.

Monad Laws

In addition to these components, for a structure to be a true monad, it must satisfy three laws: left identity, right identity, and associativity. Here’s how these might conceptually apply to the Result struct, assuming we had defined a return method:

- Left Identity: Wrapping a value in a monad and then binding a function should be the same as just applying the function to the value.
- Right Identity: Binding the return function to a monad should return the original monad unchanged.
- Associativity: The order in which operations are chained should not matter; chaining (m.Bind(f)).Bind(g) should have the same effect as (m.Bind(g)).Bind(f).

In practice, Go does not natively support monads, and the error handling pattern shown is just an emulation to streamline error handling and make code cleaner. The approach leverages struct and method features in Go to approximate the behavior of monads from functional programming, focusing on managing errors in a fluid, chainable way. This approach helps to reduce boilerplate code and makes error propagation more manageable.

--

--

Leonardo

Software developer, former civil engineer. Musician. Free thinker. Writer.