Monads.
A practical pattern for chaining operations with context. No category theory required.
What is a monad?
A monad is a design pattern for chaining operations that have some "context" or "effect".
A box that holds a value, plus rules for chaining operations on that value while preserving the context.
| Monad | Context | When you reach for it |
|---|---|---|
Option | "might not have a value"Optional / nullable fields without nil pointers. | |
Result | "might be an error"Error handling without manual if-err checks. | |
IO | "will perform side effects"Effectful computations described lazily. | |
Array | "has multiple values"Branching / one-to-many transformations. |
Why do we need monads?
- The problem
- With monads
// Without monads: error handling is messy
func processUser(id string) (User, error) {
user, err := fetchUser(id)
if err != nil {
return User{}, err
}
validated, err := validateUser(user)
if err != nil {
return User{}, err
}
enriched, err := enrichUser(validated)
if err != nil {
return User{}, err
}
saved, err := saveUser(enriched)
if err != nil {
return User{}, err
}
return saved, nil
}
// Repetitive error checking
// Hard to compose
// Lots of boilerplate
// With Result monad: clean and composable
import (
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/function"
)
func processUser(id string) result.Result[User] {
return function.Pipe4(
fetchUser(id),
result.Chain(validateUser),
result.Chain(enrichUser),
result.Chain(saveUser),
)
}
// No repetitive error checking
// Composable
// Clear data flow
// Stops at first error automaticallyThe monad pattern.
Every monad has three things.
1. A type constructor
// Option monad
type Option[A any] struct { /* ... */ }
// Result monad
type Result[A any] struct { /* ... */ }
// IO monad
type IO[A any] func() A2. A way to put values in (Return/Of)
// Option
opt := option.Some(42) // Put 42 in Option context
// Result
res := result.Ok(42) // Put 42 in Result context
// IO
io := io.Of(func() int { return 42 }) // Put 42 in IO context3. A way to chain operations (Chain/FlatMap)
// Option
result := option.Chain(func(x int) option.Option[int] {
return option.Some(x * 2)
})(opt)
// Result
result := result.Chain(func(x int) result.Result[int] {
return result.Ok(x * 2)
})(res)
// IO
result := io.Chain(func(x int) io.IO[int] {
return io.Of(func() int { return x * 2 })
})(myIO)Common monads in fp-go.
Option monad — "might not have a value"
- Without Option
- With Option
func findUser(id string) *User {
user := db.FindByID(id)
return user
}
func getEmail(user *User) *string {
if user == nil {
return nil
}
return &user.Email
}
// Lots of nil checks
user := findUser("123")
if user != nil {
email := getEmail(user)
if email != nil {
// Use email
}
}
import "github.com/IBM/fp-go/v2/option"
func findUser(id string) option.Option[User] {
user := db.FindByID(id)
if user == nil {
return option.None[User]()
}
return option.Some(*user)
}
func getEmail(user User) option.Option[string] {
if user.Email == "" {
return option.None[string]()
}
return option.Some(user.Email)
}
// Chain operations
email := option.Chain(getEmail)(findUser("123"))
// Or with Pipe
email := function.Pipe2(
findUser("123"),
option.Chain(getEmail),
)
// Handle result
email.Fold(
func() { fmt.Println("No email") },
func(e string) { fmt.Println("Email:", e) },
)Result monad — "might be an error"
- Without Result
- With Result
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func processNumbers(a, b, c int) (int, error) {
result1, err := divide(a, b)
if err != nil {
return 0, err
}
result2, err := divide(result1, c)
if err != nil {
return 0, err
}
return result2, nil
}
import "github.com/IBM/fp-go/v2/result"
func divide(a, b int) result.Result[int] {
if b == 0 {
return result.Err[int](errors.New("division by zero"))
}
return result.Ok(a / b)
}
func processNumbers(a, b, c int) result.Result[int] {
return function.Pipe2(
divide(a, b),
result.Chain(func(x int) result.Result[int] {
return divide(x, c)
}),
)
}
// Stops at first error automaticallyIO monad — "will perform side effects"
- Without IO
- With IO
func readFile(path string) ([]byte, error) {
return os.ReadFile(path) // Executes immediately
}
func parseJSON(data []byte) (Config, error) {
var config Config
err := json.Unmarshal(data, &config)
return config, err
}
// Hard to test, executes immediately
config, err := readFile("config.json")
if err != nil {
return err
}
parsed, err := parseJSON(config)
import "github.com/IBM/fp-go/v2/ioresult"
func readFile(path string) ioresult.IOResult[[]byte] {
return func() result.Result[[]byte] {
data, err := os.ReadFile(path)
return result.FromGoError(data, err)
}
}
func parseJSON(data []byte) result.Result[Config] {
var config Config
err := json.Unmarshal(data, &config)
return result.FromGoError(config, err)
}
// Build pipeline (doesn't execute yet)
loadConfig := function.Pipe2(
readFile("config.json"),
ioresult.Chain(func(data []byte) ioresult.IOResult[Config] {
return ioresult.FromResult(parseJSON(data))
}),
)
// Execute when ready
config := loadConfig() // Now it runsArray monad — "has multiple values"
import "github.com/IBM/fp-go/v2/array"
// Array is a monad!
numbers := []int{1, 2, 3}
// Chain (FlatMap) - each element produces an array
result := array.Chain(func(x int) []int {
return []int{x, x * 2}
})(numbers)
// [1, 2, 2, 4, 3, 6]
// Map - each element produces a single value
doubled := array.Map(func(x int) int {
return x * 2
})(numbers)
// [2, 4, 6]Monad operations.
Map vs. Chain
Transform the value inside.
opt := option.Some(5)
doubled := option.Map(func(x int) int {
return x * 2
})(opt)
// Some(10)Transform and return a new monad.
opt := option.Some(5)
result := option.Chain(func(x int) option.Option[int] {
if x > 0 {
return option.Some(x * 2)
}
return option.None[int]()
})(opt)
// Some(10)Use Map when your function returns a plain value. Use Chain when your function returns another monad.
Common operations across monads
// Of/Return - Put value in monad
option.Some(42)
result.Ok(42)
io.Of(func() int { return 42 })
// Map - Transform value
option.Map(func(x int) int { return x * 2 })(opt)
result.Map(func(x int) int { return x * 2 })(res)
io.Map(func(x int) int { return x * 2 })(myIO)
// Chain/FlatMap - Transform and flatten
option.Chain(func(x int) option.Option[int] {
return option.Some(x * 2)
})(opt)
result.Chain(func(x int) result.Result[int] {
return result.Ok(x * 2)
})(res)
// Fold - Extract value
option.Fold(
func() int { return 0 }, // None case
func(x int) int { return x }, // Some case
)(opt)
result.Fold(
func(err error) int { return 0 }, // Error case
func(x int) int { return x }, // Success case
)(res)The monad laws.
Three laws guarantee predictable composition. You don't have to memorize them.
Law 1 — Left identity
of(a).chain(f) === f(a)
a := 5
f := func(x int) option.Option[int] { return option.Some(x * 2) }
// These are equivalent:
result1 := option.Chain(f)(option.Some(a))
result2 := f(a)
// Both give Some(10)Law 2 — Right identity
m.chain(of) === m
m := option.Some(5) // These are equivalent: result1 := option.Chain(option.Some[int])(m) result2 := m // Both give Some(5)
Law 3 — Associativity
m.chain(f).chain(g) === m.chain(x => f(x).chain(g))
m := option.Some(5)
f := func(x int) option.Option[int] { return option.Some(x * 2) }
g := func(x int) option.Option[int] { return option.Some(x + 1) }
// These are equivalent:
result1 := option.Chain(g)(option.Chain(f)(m))
result2 := option.Chain(func(x int) option.Option[int] {
return option.Chain(g)(f(x))
})(m)
// Both give Some(11)They ensure monads compose predictably.
Real-world examples.
User registration
- Without monads
- With monads
func registerUser(email, password string) (User, error) {
if !isValidEmail(email) {
return User{}, errors.New("invalid email")
}
existing, err := db.FindByEmail(email)
if err != nil {
return User{}, err
}
if existing != nil {
return User{}, errors.New("email already exists")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return User{}, err
}
user := User{
Email: email,
Password: string(hash),
}
if err := db.Save(&user); err != nil {
return User{}, err
}
return user, nil
}
import (
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/function"
)
func validateEmail(email string) result.Result[string] {
if !isValidEmail(email) {
return result.Err[string](errors.New("invalid email"))
}
return result.Ok(email)
}
func checkNotExists(email string) result.Result[string] {
existing, err := db.FindByEmail(email)
if err != nil {
return result.Err[string](err)
}
if existing != nil {
return result.Err[string](errors.New("email already exists"))
}
return result.Ok(email)
}
func hashPassword(password string) result.Result[string] {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
return result.FromGoError(string(hash), err)
}
func createUser(email, hash string) result.Result[User] {
user := User{Email: email, Password: hash}
err := db.Save(&user)
return result.FromGoError(user, err)
}
func registerUser(email, password string) result.Result[User] {
return function.Pipe4(
validateEmail(email),
result.Chain(checkNotExists),
result.Chain(func(e string) result.Result[string] {
return hashPassword(password)
}),
result.Chain(func(hash string) result.Result[User] {
return createUser(email, hash)
}),
)
}Configuration loading
- Without monads
- With monads
func loadConfig() (Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return Config{}, err
}
var raw RawConfig
if err := json.Unmarshal(data, &raw); err != nil {
return Config{}, err
}
if raw.Port == 0 {
return Config{}, errors.New("port required")
}
config := Config{
Port: raw.Port,
Host: raw.Host,
Timeout: time.Duration(raw.TimeoutSec) * time.Second,
}
return config, nil
}
import (
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/function"
)
func readFile(path string) ioresult.IOResult[[]byte] {
return func() result.Result[[]byte] {
data, err := os.ReadFile(path)
return result.FromGoError(data, err)
}
}
func parseJSON(data []byte) result.Result[RawConfig] {
var raw RawConfig
err := json.Unmarshal(data, &raw)
return result.FromGoError(raw, err)
}
func validateConfig(raw RawConfig) result.Result[RawConfig] {
if raw.Port == 0 {
return result.Err[RawConfig](errors.New("port required"))
}
return result.Ok(raw)
}
func transformConfig(raw RawConfig) Config {
return Config{
Port: raw.Port,
Host: raw.Host,
Timeout: time.Duration(raw.TimeoutSec) * time.Second,
}
}
func loadConfig() ioresult.IOResult[Config] {
return function.Pipe4(
readFile("config.json"),
ioresult.Chain(func(data []byte) ioresult.IOResult[RawConfig] {
return ioresult.FromResult(parseJSON(data))
}),
ioresult.Chain(func(raw RawConfig) ioresult.IOResult[RawConfig] {
return ioresult.FromResult(validateConfig(raw))
}),
ioresult.Map(transformConfig),
)
}Common patterns.
Sequential operations
result := function.Pipe4( fetchUser(id), result.Chain(validateUser), result.Chain(enrichUser), result.Chain(saveUser), )
Conditional logic
result := result.Chain(func(user User) result.Result[User] {
if user.Age < 18 {
return result.Err[User](errors.New("too young"))
}
return result.Ok(user)
})(userResult)Combining results
func createOrder(userID, productID string) result.Result[Order] {
user := fetchUser(userID)
product := fetchProduct(productID)
return result.Chain(func(u User) result.Result[Order] {
return result.Map(func(p Product) Order {
return Order{User: u, Product: p}
})(product)
})(user)
}Error recovery
result := result.OrElse(func(err error) result.Result[User] {
log.Printf("Error: %v, using default", err)
return result.Ok(defaultUser)
})(userResult)When to use monads.
Chaining operations that can fail.
Handling optional values.
Managing side effects.
Building composable pipelines.
Need consistent error handling.
Simple, one-off operations.
Performance is critical (hot path).
Team unfamiliar with the pattern.
Standard Go is clearer.
Common questions.
No. Think of monads as a design pattern for chaining operations with context. The theory is interesting but not required.
No. Error handling is one use case. Monads handle any "context": Option (optional values), Result (errors), IO (side effects), Array (multiple values), Reader (dependency injection).
Depends. For simple cases, standard Go is fine. For complex error handling and composition, monads shine.
Map: function returns a plain value. Chain: function returns a monad.
// Map: int → string
result.Map(func(x int) string { return fmt.Sprint(x) })
// Chain: int → Result[string]
result.Chain(func(x int) result.Result[string] {
return result.Ok(fmt.Sprint(x))
})Summary
- Pattern for chaining with context
- Three parts: type, return, chain
- Common types: Option, Result, IO, Array
- Operations: Map, Chain, Fold
- Laws ensure predictable behavior
Monads are a practical pattern for managing context in a composable way. You don't need to understand the theory to use them effectively.