Skip to main content
Version: v2.2.82 (latest)
Concepts · 02 / 06

Monads.

A practical pattern for chaining operations with context. No category theory required.

// Mental model
A box with rules for chaining.
// Common monads
Option · Result · IO · Array.
// Key ops
Map, Chain, Fold.
01

What is a monad?

A monad is a design pattern for chaining operations that have some "context" or "effect".

Think of it as.

A box that holds a value, plus rules for chaining operations on that value while preserving the context.

MonadContextWhen 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.
02

Why do we need monads?

problem.go
// 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
03

The monad pattern.

Every monad has three things.

1. A type constructor

constructors.go
// Option monad
type Option[A any] struct { /* ... */ }

// Result monad
type Result[A any] struct { /* ... */ }

// IO monad
type IO[A any] func() A

2. A way to put values in (Return/Of)

of.go
// 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 context

3. A way to chain operations (Chain/FlatMap)

chain.go
// 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)
04

Common monads in fp-go.

Option monad — "might not have a value"

without.go
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
  }
}

Result monad — "might be an error"

without.go
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
}

IO monad — "will perform side effects"

without.go
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)

Array monad — "has multiple values"

array.gotested
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]
05

Monad operations.

Map vs. Chain

MapA → B

Transform the value inside.

map.go
opt := option.Some(5)
doubled := option.Map(func(x int) int {
  return x * 2
})(opt)
// Some(10)
ChainA → M[B]

Transform and return a new monad.

chain.go
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)
Rule of thumb.

Use Map when your function returns a plain value. Use Chain when your function returns another monad.

Common operations across monads

ops-of.go
// Of/Return - Put value in monad
option.Some(42)
result.Ok(42)
io.Of(func() int { return 42 })
ops-map.go
// 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)
ops-chain.go
// 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)
ops-fold.go
// 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)
06

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)

law1.go
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

law2.go
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))

law3.go
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)
Why these matter.

They ensure monads compose predictably.

07

Real-world examples.

User registration

without.go
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
}

Configuration loading

without.go
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
}
08

Common patterns.

Sequential operations

sequential.go
result := function.Pipe4(
  fetchUser(id),
  result.Chain(validateUser),
  result.Chain(enrichUser),
  result.Chain(saveUser),
)

Conditional logic

conditional.go
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

combine.go
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

recover.go
result := result.OrElse(func(err error) result.Result[User] {
  log.Printf("Error: %v, using default", err)
  return result.Ok(defaultUser)
})(userResult)
09

When to use monads.

Use monads whenfits

Chaining operations that can fail.

Handling optional values.

Managing side effects.

Building composable pipelines.

Need consistent error handling.

Don't force whentrade-offs

Simple, one-off operations.

Performance is critical (hot path).

Team unfamiliar with the pattern.

Standard Go is clearer.

10

Common questions.

Do I need to understand category theory?

No. Think of monads as a design pattern for chaining operations with context. The theory is interesting but not required.

Aren't monads just error handling?

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).

Is this overengineering?

Depends. For simple cases, standard Go is fine. For complex error handling and composition, monads shine.

Map vs. Chain — quick rule.

Map: function returns a plain value. Chain: function returns a monad.

rule.go
// 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))
})
11

Summary

Monads5 / 5 complete
  • Pattern for chaining with context
  • Three parts: type, return, chain
  • Common types: Option, Result, IO, Array
  • Operations: Map, Chain, Fold
  • Laws ensure predictable behavior
Key takeaway.

Monads are a practical pattern for managing context in a composable way. You don't need to understand the theory to use them effectively.