Packages

Schema validation for Gleam — constraints, error accumulation, and field paths

Current section

Files

Jump to
sift src sift.gleam
Raw

src/sift.gleam

//// Core validation functions — check fields, accumulate errors, compose validators.
import gleam/int
import gleam/list
/// A validation error with path to the field and a human-readable message.
///
/// ```gleam
/// FieldError(path: ["email"], message: "required")
/// FieldError(path: ["address", "zip"], message: "must be 5 digits")
/// ```
pub type FieldError {
FieldError(path: List(String), message: String)
}
/// Accumulated validation result — value + errors collected so far
/// Using a tuple (not Result) enables the `use` pattern to run ALL validators
pub type Validated(a) =
#(a, List(FieldError))
/// A single constraint: takes a value, returns Ok(value) or Error(message)
pub type Validator(a) =
fn(a) -> Result(a, String)
/// Run a validator on a field value, accumulate errors, feeds into `use`.
///
/// ```gleam
/// use name <- sift.check("name", input.name, s.min_length(1, "required"))
/// use email <- sift.check("email", input.email, s.email("invalid"))
/// sift.ok(User(name:, email:))
/// ```
pub fn check(
field: String,
value: a,
validator: Validator(a),
next: fn(a) -> Validated(b),
) -> Validated(b) {
case validator(value) {
Ok(v) -> next(v)
Error(msg) -> {
let #(result, errors) = next(value)
#(result, [FieldError(path: [field], message: msg), ..errors])
}
}
}
/// Run a sub-validator function, prefixing error paths with the field name.
///
/// ```gleam
/// use address <- sift.nested("address", input.address, validate_address)
/// // errors get paths like ["address", "zip"]
/// ```
pub fn nested(
field: String,
value: a,
validator_fn: fn(a) -> Validated(b),
next: fn(b) -> Validated(c),
) -> Validated(c) {
let #(inner_value, inner_errors) = validator_fn(value)
let prefixed_errors =
list.map(inner_errors, fn(e) {
FieldError(path: [field, ..e.path], message: e.message)
})
let #(result, outer_errors) = next(inner_value)
#(result, list.append(prefixed_errors, outer_errors))
}
/// Wrap a final value into a Validated tuple with no errors
pub fn ok(value: a) -> Validated(a) {
#(value, [])
}
/// Convert a Validated(a) to Result(a, List(FieldError)).
///
/// ```gleam
/// sift.ok(User(name: "Jo", email: "jo@example.com"))
/// |> sift.validate
/// // -> Ok(User(name: "Jo", email: "jo@example.com"))
/// ```
pub fn validate(validated: Validated(a)) -> Result(a, List(FieldError)) {
case validated {
#(value, []) -> Ok(value)
#(_, errors) -> Error(errors)
}
}
/// Compose two validators — run both, accumulate errors from both.
///
/// ```gleam
/// let validator = s.min_length(1, "required") |> sift.and(s.email("invalid"))
/// ```
pub fn and(
v1: Validator(a),
v2: Validator(a),
) -> Validator(a) {
fn(value) {
case v1(value), v2(value) {
Ok(a), Ok(_) -> Ok(a)
Ok(_), Error(msg) -> Error(msg)
Error(msg), Ok(_) -> Error(msg)
// When both fail, return the first error (second is lost in this simple compose)
Error(msg), _ -> Error(msg)
}
}
}
/// Validate every item in a list, accumulating indexed error paths.
/// Produces paths like `["tags", "0"]`, `["tags", "1"]`, etc.
///
/// ```gleam
/// use tags <- sift.each("tags", input.tags, s.non_empty("empty tag"))
/// // invalid items get paths like ["tags", "2"]
/// ```
pub fn each(
field: String,
items: List(a),
validator: Validator(a),
next: fn(List(a)) -> Validated(b),
) -> Validated(b) {
let item_errors =
items
|> list.index_map(fn(item, idx) {
case validator(item) {
Ok(_) -> []
Error(msg) -> [
FieldError(
path: [field, int.to_string(idx)],
message: msg,
),
]
}
})
|> list.flatten
let #(result, outer_errors) = next(items)
#(result, list.append(item_errors, outer_errors))
}
/// Pass if either validator succeeds (try v1 first, then v2).
///
/// ```gleam
/// let validator = s.email("invalid") |> sift.or(s.url("invalid"))
/// ```
pub fn or(
v1: Validator(a),
v2: Validator(a),
) -> Validator(a) {
fn(value) {
case v1(value) {
Ok(v) -> Ok(v)
Error(_) -> v2(value)
}
}
}
/// Invert a validator — fail if it passes, pass if it fails.
///
/// ```gleam
/// let not_admin = sift.not(s.one_of(["admin"], ""), "cannot be admin")
/// ```
pub fn not(
validator: Validator(a),
msg: String,
) -> Validator(a) {
fn(value) {
case validator(value) {
Ok(_) -> Error(msg)
Error(_) -> Ok(value)
}
}
}
/// Value must equal the expected value
pub fn equals(expected: a, msg: String) -> Validator(a) {
fn(value) {
case value == expected {
True -> Ok(value)
False -> Error(msg)
}
}
}
/// Escape hatch for user-defined checks
pub fn custom(f: fn(a) -> Result(a, String)) -> Validator(a) {
f
}