README.md

```text
    ╭────────────────────────────────────╮
    │      ╭────╮  ╭────╮  ╭────╮        │
    │      │    │  │    │  │    │        │
    ╰──────┴────┴──┴────┴──┴────┴────────╯
    
    ═══════════════════════════════════════
    
    ════════════ rectify ═══════════════
    
    ═══════════════════════════════════════
    
    ╭──────┬────┬──┬────┬──┬────┬────────╮
    │      │    │  │    │  │    │        │
    │      ╰────╯  ╰────╯  ╰────╯        │
    ╰────────────────────────────────────╯
```

# rectify

[![Package Version](https://img.shields.io/hexpm/v/rectify)](https://hex.pm/packages/rectify)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/rectify/)

Railway-oriented programming utilities for Gleam. A port of FsToolkit.ErrorHandling concepts, focusing on **accumulating validation errors** instead of failing fast.

```sh
gleam add rectify@1
```

## Quick Start

```gleam
import rectify

// Individual validators return Validation
type User {
  User(name: String, email: String, age: Int)
}

fn validate_name(name: String) -> rectify.Validation(String, String) {
  case string.trim(name) {
    "" -> rectify.invalid("Name is required")
    n -> rectify.valid(n)
  }
}

fn validate_email(email: String) -> rectify.Validation(String, String) {
  case string.contains(email, "@") {
    True -> rectify.valid(email)
    False -> rectify.invalid("Invalid email address")
  }
}

fn validate_age(age: Int) -> rectify.Validation(Int, String) {
  case age >= 0 && age <= 150 {
    True -> rectify.valid(age)
    False -> rectify.invalid("Age must be between 0 and 150")
  }
}

// Collect ALL errors, not just the first one
let result = rectify.map3(
  validate_name(""),
  validate_email("not-an-email"),
  validate_age(200),
  User,
)

// result = Invalid(["Name is required", "Invalid email address", "Age must be between 0 and 150"])
// vs Result which would only give you the first error
```

## Why Validation instead of Result?

| `Result(a, e)` | `Validation(a, e)` |
|----------------|-------------------|
| Stops at first error | Accumulates all errors |
| Single error in `Error(e)` | List of errors in `Invalid(List(e))` |
| Good for early exit | Good for form validation |
| Fail-fast | Report-all |

## Modules

### `rectify` - Validation

The core `Validation` type for accumulating errors.

```gleam
import rectify

// Constructors
rectify.valid(42)                    // Valid(42)
rectify.invalid("oops")              // Invalid(["oops"])
rectify.invalid_many(["a", "b"])     // Invalid(["a", "b"])

// Mapping - errors accumulate!
rectify.map2(valid(2), valid(3), fn(a, b) { a + b })        // Valid(5)
rectify.map2(invalid("a"), invalid("b"), fn(a, b) { a + b }) // Invalid(["a", "b"])
rectify.map3(v1, v2, v3, User)  // Up to map5 available

// Conversions
rectify.to_result(valid(42))           // Ok(42)
rectify.of_result(Error("e"))          // Invalid(["e"])
```

### `rectify/option` - Option Utilities

Additional helpers for Gleam's `Option` type.

```gleam
import rectify/option as ropt

// Defaults
ropt.unwrap_lazy(None, fn() { expensive() })  // Lazily compute default

// Combining
ropt.map2(Some(2), Some(3), fn(a, b) { a + b })  // Some(5)
ropt.map3(opt1, opt2, opt3, fn(a, b, c) { a + b + c })  // Up to map5 available
ropt.zip(Some(1), Some(2))                       // Some(#(1, 2))
ropt.zip3(Some(1), Some(2), Some(3))             // Some(#(1, 2, 3))

// Collections
ropt.choose_somes([Some(1), None, Some(2)])      // [1, 2]
ropt.first_some([None, Some(2), Some(3)])        // Some(2)
ropt.traverse(["1", "2", "3"], int.parse)        // Some([1, 2, 3])
ropt.sequence([Some(1), Some(2), Some(3)])       // Some([1, 2, 3])

// Conversions
ropt.to_result(Some(42), "not found")  // Ok(42)
ropt.of_result(Ok(42))                 // Some(42)
```

### `rectify/result_option` - Result<Option> Helpers

For working with `Result(Option(a), e)` - a common pattern for operations that can fail AND may not return a value.

```gleam
import rectify/result_option as ro

// Constructors
ro.some(42)        // Ok(Some(42))
ro.none()          // Ok(None)
ro.error("e")      // Error("e")

// Mapping
ro.map(Ok(Some(5)), fn(n) { n * 2 })     // Ok(Some(10))
ro.bind(Ok(Some(5)), fn(n) { ro.some(n * 2) })

// Combining
ro.zip(Ok(Some(1)), Ok(Some(2)))         // Ok(Some(#(1, 2)))
ro.zip3(ro1, ro2, ro3)                   // Ok(Some(#(a, b, c))) or Ok(None) or Error

// Collections
ro.traverse([1, 2, 3], find_user)        // Ok(Some([users])) or Ok(None) or Error
ro.sequence([ro1, ro2, ro3])             // Ok(Some([values])) or Ok(None) or Error

// Predicates
ro.is_some(Ok(Some(42)))     // True
ro.is_none(Ok(None))         // True

// Conversions
ro.to_option(Ok(Some(42)))        // Some(42)
ro.of_option(Some(42))            // Ok(Some(42))
ro.of_result(Ok(42))              // Ok(Some(42))
ro.to_result(Ok(Some(42)), 0)     // Ok(42)
ro.to_result(Ok(None), 0)         // Ok(0) - default value

// Defaults (unwrap Option inside Result)
ro.unwrap_option(Ok(None), 0)           // Ok(0) - provide default for None
ro.unwrap_option_lazy(Ok(None), fn() { expensive() })  // lazy default
```

## Common Patterns

### Form Validation

```gleam
import rectify

type Form {
  Form(name: String, email: String, age: Int)
}

fn validate_form(name: String, email: String, age: Int) {
  rectify.map3(
    validate_name(name),
    validate_email(email),
    validate_age(age),
    Form,
  )
  |> rectify.to_result  // Convert to Result for standard error handling
}

// Usage
case validate_form("", "bad-email", -5) {
  Ok(form) -> create_user(form)
  Error(errors) -> show_validation_errors(errors)
}
```

### Option Chaining

```gleam
import rectify/option as ropt
import gleam/option.{Some, None}

// Combine multiple optional lookups
let result = ropt.map3(
  dict.get(users, "alice"),     // Some(user1)
  dict.get(users, "bob"),       // None
  dict.get(users, "charlie"),   // Some(user3)
  fn(a, b, c) { [a, b, c] }
)
// result = None (because bob was None)

// Find first available fallback
ropt.first_some([
  dict.get(config, "primary_url"),
  dict.get(config, "fallback_url"),
  Some("default"),
])

// Combine lookups into a tuple
ropt.zip(
  dict.get(config, "host"),
  dict.get(config, "port"),
)
// -> Some(#("localhost", "8080"))
```

### Option Traverse

```gleam
import rectify/option as ropt
import gleam/int

// Parse all strings - fails if any parse fails
ropt.traverse(["1", "2", "3"], int.parse)
// -> Some([1, 2, 3])

ropt.traverse(["1", "bad", "3"], int.parse)
// -> None

// Look up multiple keys
let user_ids = [1, 2, 3]
let users = dict.from_list([...])
ropt.traverse(user_ids, fn(id) { dict.get(users, id) })
// -> Some([user1, user2, user3])  (or None if any missing)

// Flip a list of Options
ropt.sequence([Some(1), Some(2), Some(3)])
// -> Some([1, 2, 3])
```

### Result<Option> Pipeline

```gleam
import rectify/result_option as ro

// Database lookup that can fail (Error) or not find result (Ok(None))
fn find_user(id: Int) -> Result(Option(User), DbError) {
  // ... database code
}

// Transform through pipeline
find_user(42)
|> ro.map(fn(user) { user.name })
|> ro.bind(fn(name) { 
  case name {
    "" -> ro.none()
    _ -> ro.some(name)
  }
})
|> ro.to_result("unknown")  // Get Result(String, String)
```

### Result<Option> Traverse

```gleam
import rectify/result_option as ro

// Database lookup that can error or return None
fn find_user(id: Int) -> Result(Option(User), DbError) { ... }

// Look up multiple users - fails fast on error, returns None if any not found
ro.traverse([1, 2, 3], find_user)
// -> Error(db_error) if any query fails
// -> Ok(None) if any user not found
// -> Ok(Some([user1, user2, user3])) if all found

// Combine multiple lookups
ro.zip3(
  find_user(1),
  find_address(1),
  find_preferences(1),
)
// -> Ok(Some(#(user, address, prefs))) if all found
// -> Ok(None) if any not found
// -> Error(db_error) if any query fails

// Flip a list of Result(Option)
let lookups = [find_user(1), find_user(2), find_user(3)]
ro.sequence(lookups)
// -> Ok(Some([users])) or Ok(None) or Error(db_error)
```

## Comparison with Gleam's Result

```gleam
// Result - fail fast
let r1 = Ok(1)
let r2 = Error("error 1")
let r3 = Error("error 2")

use a <- result.try(r1)
use b <- result.try(r2)  // Stops here, never sees "error 2"
use c <- result.try(r3)
Ok(a + b + c)
// Error("error 1")

// Validation - collect all
let v1 = rectify.valid(1)
let v2 = rectify.invalid("error 1")
let v3 = rectify.invalid("error 2")

rectify.map3(v1, v2, v3, fn(a, b, c) { a + b + c })
// Invalid(["error 1", "error 2"])
```

## Mathematical Soundness

Rectify's `Validation` type is a **lawful Applicative Functor**, verified through comprehensive property-based testing:

- ✅ **Functor Laws** - Identity and Composition
- ✅ **Applicative Laws** - Identity, Homomorphism, Interchange, and Composition
- ✅ **Monad Laws** - Left/Right Identity and Associativity (for Valid cases)
- ✅ **Error Accumulation Properties** - Verified for map2 through map5
- ✅ **112 tests total** - 26 property-based law tests + 86 unit tests

These laws guarantee:
- **Predictability** - Code behaves consistently regardless of structure
- **Composability** - Small pieces combine correctly into larger pieces
- **Refactorability** - Safe transformations without changing behavior
- **Optimization** - Compilers can safely optimize your code

See [VALIDATION_LAWS.md](VALIDATION_LAWS.md) for detailed explanations of each law and why they matter.

## Development

```sh
gleam run   # Run the project
gleam test  # Run the tests
gleam docs   # Generate documentation
```

## Acknowledgements

Inspired by the excellent [FsToolkit.ErrorHandling](https://github.com/demystifyfp/FsToolkit.ErrorHandling) library for F#.

## License

This project is licensed under the [MIT License](LICENSE).