╭────────────────────────────────────╮
│ ╭────╮ ╭────╮ ╭────╮ │
│ │ │ │ │ │ │ │
╰──────┴────┴──┴────┴──┴────┴────────╯
═══════════════════════════════════════
════════════ rectify ═══════════════
═══════════════════════════════════════
╭──────┬────┬──┬────┬──┬────┬────────╮
│ │ │ │ │ │ │ │
│ ╰────╯ ╰────╯ ╰────╯ │
╰────────────────────────────────────╯
Railway-oriented programming utilities for Gleam. A port of FsToolkit.ErrorHandling concepts, focusing on accumulating validation errors instead of failing fast.
gleam add rectify@1import 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 errorResult(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 |
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"])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)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 defaultimport 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)
}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"))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])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)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)// 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"])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.
gleam run # Run the project
gleam test # Run the tests
gleam docs # Generate documentationInspired by the excellent FsToolkit.ErrorHandling library for F#.
This project is licensed under the MIT License.