Skip to content

code-shoily/rectify

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

rectify

Package Version Hex Docs

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

gleam add rectify@1

Quick Start

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.

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.

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 Helpers

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

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

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

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

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 Pipeline

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 Traverse

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

// 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 for detailed explanations of each law and why they matter.

Development

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

Acknowledgements

Inspired by the excellent FsToolkit.ErrorHandling library for F#.

License

This project is licensed under the MIT License.

About

Railway-oriented programming utilities for Gleam.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages