//// Core validation functions — check fields, accumulate errors, compose validators. //// //// Build a validator for a struct by chaining `check` calls with `use`, //// then finish with `ok` and convert to a `Result` with `validate`. Every //// field runs, so a single call returns every error at once — not just the //// first. //// //// ```gleam //// import sift //// import sift/int as i //// import sift/string as s //// //// pub type User { User(name: String, email: String, age: Int) } //// //// pub fn validate_user(input: User) -> Result(User, List(sift.FieldError(String))) { //// sift.validate({ //// use name <- sift.check("name", input.name, s.non_empty("required")) //// use email <- sift.check("email", input.email, s.email("invalid")) //// use age <- sift.check("age", input.age, i.between(0, 150, "out of range")) //// sift.ok(User(name:, email:, age:)) //// }) //// } //// ``` //// //// For nested structs use `nested`, for lists use `each`, for multiple //// constraints on one field use `check_all`, and for conditional //// constraints use `when`. See `example/contacts/` for a full walkthrough. import gleam/int import gleam/list import gleam/option.{type Option, None, Some} /// A validation error with a path to the field and an error payload. /// /// The payload is generic: use `String` for plain messages, or your own type /// for error codes, i18n keys, or severity levels. /// /// ```gleam /// FieldError(path: ["email"], error: "required") /// FieldError(path: ["address", "zip"], error: "must be 5 digits") /// ``` pub type FieldError(e) { FieldError(path: List(String), error: e) } /// 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, e) = #(a, List(FieldError(e))) /// A single constraint: takes a value, returns Ok(value) or Error(error) pub type Validator(a, e) = fn(a) -> Result(a, e) /// 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, e), next: fn(a) -> Validated(b, e), ) -> Validated(b, e) { case validator(value) { Ok(v) -> next(v) Error(msg) -> { let #(result, errors) = next(value) #(result, [FieldError(path: [field], error: 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, e), next: fn(b) -> Validated(c, e), ) -> Validated(c, e) { let #(inner_value, inner_errors) = validator_fn(value) let prefixed_errors = list.map(inner_errors, fn(err) { FieldError(path: [field, ..err.path], error: err.error) }) 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, e) { #(value, []) } /// Convert a Validated(a, e) to Result(a, List(FieldError(e))). /// /// ```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, e)) -> Result(a, List(FieldError(e))) { 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, e), v2: Validator(a, e)) -> Validator(a, e) { 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, e), next: fn(List(a)) -> Validated(b, e), ) -> Validated(b, e) { let item_errors = items |> list.index_map(fn(item, idx) { case validator(item) { Ok(_) -> [] Error(msg) -> [ FieldError(path: [field, int.to_string(idx)], error: 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, e), v2: Validator(a, e)) -> Validator(a, e) { fn(value) { case v1(value) { Ok(v) -> Ok(v) Error(_) -> v2(value) } } } /// Invert a validator — fail if it passes, pass if it fails. /// /// The inverted validator's own error is discarded, so its error type is /// independent of `msg`'s — pass any placeholder you like. /// /// ```gleam /// let not_admin = sift.not(s.one_of(["admin"], Nil), "cannot be admin") /// ``` pub fn not(validator: fn(a) -> Result(a, x), msg: e) -> Validator(a, e) { fn(value) { case validator(value) { Ok(_) -> Error(msg) Error(_) -> Ok(value) } } } /// Value must equal the expected value. /// /// ```gleam /// let validator = sift.equals("yes", "must accept terms") /// validator("yes") // -> Ok("yes") /// validator("no") // -> Error("must accept terms") /// ``` pub fn equals(expected: a, msg: e) -> Validator(a, e) { fn(value) { case value == expected { True -> Ok(value) False -> Error(msg) } } } /// Run multiple validators on a field, accumulate all errors. /// /// ```gleam /// use name <- sift.check_all("name", input.name, [ /// s.non_empty("required"), /// s.min_length(3, "too short"), /// s.max_length(100, "too long"), /// ]) /// sift.ok(name) /// ``` pub fn check_all( field: String, value: a, validators: List(Validator(a, e)), next: fn(a) -> Validated(b, e), ) -> Validated(b, e) { let field_errors = validators |> list.filter_map(fn(v) { case v(value) { Ok(_) -> Error(Nil) Error(msg) -> Ok(FieldError(path: [field], error: msg)) } }) let #(result, outer_errors) = next(value) #(result, list.append(field_errors, outer_errors)) } /// Conditional validator — runs the validator only when condition is True. /// /// ```gleam /// use state <- sift.check("state", input.state, /// sift.when(country == "US", s.non_empty("required"))) /// ``` pub fn when(condition: Bool, validator: Validator(a, e)) -> Validator(a, e) { fn(value) { case condition { True -> validator(value) False -> Ok(value) } } } /// Validate an Option value only when Some, skip when None. /// /// ```gleam /// use nickname <- sift.check_optional("nickname", input.nickname, /// s.min_length(2, "too short")) /// sift.ok(User(nickname:)) /// ``` pub fn check_optional( field: String, value: Option(a), validator: Validator(a, e), next: fn(Option(a)) -> Validated(b, e), ) -> Validated(b, e) { case value { None -> next(None) Some(v) -> case validator(v) { Ok(_) -> next(Some(v)) Error(msg) -> { let #(result, errors) = next(Some(v)) #(result, [FieldError(path: [field], error: msg), ..errors]) } } } } /// Parse a raw value and feed the result into the chain. /// On success, passes the parsed value to next. /// On failure, records a FieldError and passes the default to next /// so that subsequent fields still validate. /// /// ```gleam /// use age <- sift.check_parse("age", "42", int.parse, 0, "must be a number") /// use age <- sift.check("age", age, i.min(13, "must be at least 13")) /// sift.ok(age) /// ``` pub fn check_parse( field: String, value: a, parser: fn(a) -> Result(b, c), default: b, msg: e, next: fn(b) -> Validated(d, e), ) -> Validated(d, e) { case parser(value) { Ok(parsed) -> next(parsed) Error(_) -> { let #(result, errors) = next(default) #(result, [FieldError(path: [field], error: msg), ..errors]) } } } /// Validate every item in a list with a sub-validator function, prefixing /// error paths with the field name and the item's index. /// Produces paths like `["tags", "0", "name"]`. /// /// Use this when each item is itself a struct to validate. For a single /// `Validator(a, e)` per item, use `each` instead. /// /// ```gleam /// use tags <- sift.check_each("tags", input.tags, validate_tag) /// // errors get paths like ["tags", "2", "name"] /// ``` pub fn check_each( field: String, values: List(a), validator_fn: fn(a) -> Validated(b, e), next: fn(List(b)) -> Validated(c, e), ) -> Validated(c, e) { let #(validated_values, item_errors) = values |> list.index_map(fn(item, idx) { let #(value, errors) = validator_fn(item) let prefixed = list.map(errors, fn(err) { FieldError( path: [field, int.to_string(idx), ..err.path], error: err.error, ) }) #(value, prefixed) }) |> list.fold(#([], []), fn(acc, pair) { let #(values_acc, errors_acc) = acc let #(value, errors) = pair #([value, ..values_acc], list.append(errors_acc, errors)) }) let validated_values = list.reverse(validated_values) let #(result, outer_errors) = next(validated_values) #(result, list.append(item_errors, outer_errors)) } /// Cross-field validator comparing two already-validated values. /// On success, passes the (possibly transformed) first value to next. /// On failure, records a FieldError under `field` and passes `a` through /// so subsequent checks still run. /// /// ```gleam /// use name <- sift.check("name", input.name, s.non_empty("required")) /// use confirm <- sift.check("confirm", input.confirm, s.non_empty("required")) /// use name <- sift.check2("confirm", name, confirm, fn(a, b) { /// case a == b { True -> Ok(a) False -> Error("must match name") } /// }) /// sift.ok(name) /// ``` pub fn check2( field: String, a: a, b: b, validator: fn(a, b) -> Result(a, e), next: fn(a) -> Validated(c, e), ) -> Validated(c, e) { case validator(a, b) { Ok(v) -> next(v) Error(msg) -> { let #(result, errors) = next(a) #(result, [FieldError(path: [field], error: msg), ..errors]) } } } /// Post-assembly whole-object check. Runs on a `Validated(a, e)` produced by /// `ok(...)`, useful for cross-field constraints expressed in terms of the /// final struct. /// /// ```gleam /// sift.ok(Registration(role:, mfa:)) /// |> sift.refine("mfa", fn(r) { /// case r.role == "admin" && r.mfa == None { /// True -> Error("required for admins") /// False -> Ok(r) /// } /// }) /// |> sift.validate /// ``` pub fn refine( validated: Validated(a, e), field: String, check: fn(a) -> Result(a, e), ) -> Validated(a, e) { let #(value, errors) = validated case check(value) { Ok(v) -> #(v, errors) Error(msg) -> #( value, list.append(errors, [FieldError(path: [field], error: msg)]), ) } } /// Escape hatch for user-defined checks. /// /// ```gleam /// let even = sift.custom(fn(n: Int) { /// case n % 2 == 0 { /// True -> Ok(n) /// False -> Error("must be even") /// } /// }) /// even(4) // -> Ok(4) /// even(3) // -> Error("must be even") /// ``` pub fn custom(f: fn(a) -> Result(a, e)) -> Validator(a, e) { f }