164 lines
6.7 KiB
Markdown
164 lines
6.7 KiB
Markdown
# Functional Programming and Monads in Go
|
|
|
|
## What is Functional Programming?
|
|
|
|
Functional programming (FP) treats computation as evaluation of mathematical functions. Core principles:
|
|
|
|
- **Immutability** — data doesn't change after creation; transformations produce new values
|
|
- **Pure functions** — same input always produces same output, no side effects
|
|
- **Composition** — build complex behavior by chaining simple functions
|
|
- **Types as documentation** — types express constraints and invariants
|
|
|
|
Go isn't a pure FP language, but Go 1.18+ generics make FP patterns practical. samber/mo brings the most battle-tested FP abstractions — monads — to Go.
|
|
|
|
## What is a Monad?
|
|
|
|
A monad is a design pattern (not a class or interface) that:
|
|
|
|
1. **Wraps a value** in a context (Option wraps "maybe absent", Result wraps "maybe failed")
|
|
2. **Chains operations** that transform the wrapped value without unwrapping it
|
|
3. **Handles the context automatically** — if an Option is None, Map/FlatMap skip the transformation; if a Result is Err, subsequent Maps short-circuit
|
|
|
|
Think of it as a **container with a policy**: "I hold a value, and I know what to do when operations succeed or fail."
|
|
|
|
### The Railway Metaphor
|
|
|
|
Imagine two parallel railway tracks:
|
|
|
|
- **Happy track** (top): data flows through transformations successfully
|
|
- **Error track** (bottom): once something goes wrong, the train switches to the error track and skips remaining transformations
|
|
|
|
```
|
|
Input → [Transform A] → [Transform B] → [Transform C] → Output
|
|
↓ (error) (skipped) (skipped)
|
|
Error track ────────────────────────────────────→ Error
|
|
```
|
|
|
|
This is exactly how Result.Map and Result.FlatMap work — errors propagate automatically without explicit if/else checks.
|
|
|
|
## Why Monads Are Valuable in Go
|
|
|
|
### 1. Compile-Time Nil Safety (Option)
|
|
|
|
Go's type system doesn't distinguish "this pointer could be nil" from "this pointer is always valid". Option[T] makes this explicit:
|
|
|
|
```go
|
|
// Without mo — caller must remember to check nil
|
|
func FindUser(id string) *User { ... } // might return nil
|
|
|
|
// With mo — the type TELLS you it might be absent
|
|
func FindUser(id string) mo.Option[User] { ... } // caller must handle None
|
|
```
|
|
|
|
The type signature is the documentation. No nil pointer panics at runtime — the compiler forces you to handle absence.
|
|
|
|
### 2. Railway-Oriented Error Handling (Result)
|
|
|
|
Go's idiomatic error handling requires checking errors at every step:
|
|
|
|
```go
|
|
// Without mo — repetitive error checking
|
|
data, err := readFile(path)
|
|
if err != nil { return err }
|
|
config, err := parseConfig(data)
|
|
if err != nil { return err }
|
|
validated, err := validate(config)
|
|
if err != nil { return err }
|
|
```
|
|
|
|
With Result and `mo.Do`, errors short-circuit through the chain:
|
|
|
|
```go
|
|
// With mo — errors propagate automatically via Do notation
|
|
result := mo.Do(func() Config {
|
|
data := mo.TupleToResult(readFile(path)).MustGet()
|
|
config := mo.TupleToResult(parseConfig(data)).MustGet()
|
|
validated := mo.TupleToResult(validate(config)).MustGet()
|
|
return validated
|
|
})
|
|
```
|
|
|
|
Same logic, less boilerplate. The error path is handled by the monad — any `MustGet()` failure short-circuits to `Err`.
|
|
|
|
**Note:** Direct `.Map`/`.FlatMap` methods cannot change the type parameter (Go methods cannot introduce new generic types). For type-changing pipelines, use sub-package `result.Pipe` functions or `mo.Do` notation as shown above.
|
|
|
|
### 3. Composable Pipelines
|
|
|
|
Monads compose naturally. You can build complex data transformations from simple, testable pieces:
|
|
|
|
```go
|
|
import "github.com/samber/mo/option"
|
|
|
|
result := option.Pipe3(
|
|
getUserOption(id),
|
|
option.Map(func(u User) string { return u.Email }),
|
|
option.FlatMap(func(email string) mo.Option[string] {
|
|
if isValid(email) { return mo.Some(email) }
|
|
return mo.None[string]()
|
|
}),
|
|
option.Map(func(email string) EmailAddress { return NewEmailAddress(email) }),
|
|
)
|
|
```
|
|
|
|
Each step is a pure function. The pipeline handles None propagation. Each step is independently testable.
|
|
|
|
## The Three Core Monads
|
|
|
|
### Option — Represents Absence
|
|
|
|
**Problem it solves:** nil pointer panics, ambiguous zero values.
|
|
|
|
| Concept | Go without mo | Go with mo |
|
|
| ------------- | --------------------- | ------------------------- |
|
|
| Value present | `*User` (non-nil) | `mo.Some(user)` |
|
|
| Value absent | `*User` (nil) | `mo.None[User]()` |
|
|
| Safe access | `if u != nil { ... }` | `opt.OrElse(defaultUser)` |
|
|
| Transform | manual nil check | `opt.Map(transform)` |
|
|
|
|
Use Option when: a value might legitimately be absent (nullable DB columns, optional config, cache lookups).
|
|
|
|
Don't use Option when: a zero value is meaningful (empty string is valid, 0 is a valid count).
|
|
|
|
### Result — Represents Fallibility
|
|
|
|
**Problem it solves:** verbose error checking, lost error context in chains.
|
|
|
|
| Concept | Go without mo | Go with mo |
|
|
| --------- | ---------------------------- | ----------------------------- |
|
|
| Success | `return value, nil` | `mo.Ok(value)` |
|
|
| Failure | `return zero, err` | `mo.Err[T](err)` |
|
|
| Chain ops | `if err != nil` at each step | `.Map(...)` / `.FlatMap(...)` |
|
|
| Default | manual fallback | `.OrElse(default)` |
|
|
|
|
Use Result when: you're chaining multiple fallible operations and want errors to propagate automatically.
|
|
|
|
Don't use Result when: you need to inspect or modify the error at each step (standard Go error handling is more explicit).
|
|
|
|
### Either — Represents Alternatives
|
|
|
|
**Problem it solves:** functions that legitimately return one of two types.
|
|
|
|
| Concept | Example |
|
|
| ---------------------- | --------------------------------- |
|
|
| Cached vs fresh data | `Either[CachedUser, FreshUser]` |
|
|
| Sync vs async result | `Either[SyncResult, AsyncResult]` |
|
|
| Left vs right strategy | `Either[StrategyA, StrategyB]` |
|
|
|
|
Use Either when: both outcomes are valid, neither is an "error". If one side is always an error, use Result instead.
|
|
|
|
## When to Use mo vs Plain Go
|
|
|
|
**Use mo when:**
|
|
|
|
- You're building data transformation pipelines with multiple steps
|
|
- You need type-safe nullable values (especially in JSON/DB models)
|
|
- Error handling chains become repetitive
|
|
- You want to make impossible states unrepresentable in the type system
|
|
|
|
**Stick with plain Go when:**
|
|
|
|
- Simple one-step operations where `if err != nil` is clear enough
|
|
- Performance-critical hot paths (monads add thin allocation overhead)
|
|
- Your team isn't familiar with FP concepts (readability > cleverness)
|
|
- The operation has complex error recovery at each step (explicit handling is clearer)
|