sift

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.

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)) {
  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:))
  |> sift.validate
}

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.

Types

A validation error with path to the field and a human-readable message.

FieldError(path: ["email"], message: "required")
FieldError(path: ["address", "zip"], message: "must be 5 digits")
pub type FieldError {
  FieldError(path: List(String), message: String)
}

Constructors

  • 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)

Values

pub fn and(
  v1: fn(a) -> Result(a, String),
  v2: fn(a) -> Result(a, String),
) -> fn(a) -> Result(a, String)

Compose two validators — run both, accumulate errors from both.

let validator = s.min_length(1, "required") |> sift.and(s.email("invalid"))
pub fn check(
  field: String,
  value: a,
  validator: fn(a) -> Result(a, String),
  next: fn(a) -> #(b, List(FieldError)),
) -> #(b, List(FieldError))

Run a validator on a field value, accumulate errors, feeds into use.

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 check2(
  field: String,
  a: a,
  b: b,
  validator: fn(a, b) -> Result(a, String),
  next: fn(a) -> #(c, List(FieldError)),
) -> #(c, List(FieldError))

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.

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 check_all(
  field: String,
  value: a,
  validators: List(fn(a) -> Result(a, String)),
  next: fn(a) -> #(b, List(FieldError)),
) -> #(b, List(FieldError))

Run multiple validators on a field, accumulate all errors.

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_each(
  field: String,
  values: List(a),
  validator_fn: fn(a) -> #(b, List(FieldError)),
  next: fn(List(b)) -> #(c, List(FieldError)),
) -> #(c, List(FieldError))

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) per item, use each instead.

use tags <- sift.check_each("tags", input.tags, validate_tag)
// errors get paths like ["tags", "2", "name"]
pub fn check_optional(
  field: String,
  value: option.Option(a),
  validator: fn(a) -> Result(a, String),
  next: fn(option.Option(a)) -> #(b, List(FieldError)),
) -> #(b, List(FieldError))

Validate an Option value only when Some, skip when None.

use nickname <- sift.check_optional("nickname", input.nickname,
  s.min_length(2, "too short"))
sift.ok(User(nickname:))
pub fn check_parse(
  field: String,
  value: a,
  parser: fn(a) -> Result(b, c),
  default: b,
  msg: String,
  next: fn(b) -> #(d, List(FieldError)),
) -> #(d, List(FieldError))

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.

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 custom(
  f: fn(a) -> Result(a, String),
) -> fn(a) -> Result(a, String)

Escape hatch for user-defined checks.

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 each(
  field: String,
  items: List(a),
  validator: fn(a) -> Result(a, String),
  next: fn(List(a)) -> #(b, List(FieldError)),
) -> #(b, List(FieldError))

Validate every item in a list, accumulating indexed error paths. Produces paths like ["tags", "0"], ["tags", "1"], etc.

use tags <- sift.each("tags", input.tags, s.non_empty("empty tag"))
// invalid items get paths like ["tags", "2"]
pub fn equals(
  expected: a,
  msg: String,
) -> fn(a) -> Result(a, String)

Value must equal the expected value.

let validator = sift.equals("yes", "must accept terms")
validator("yes")  // -> Ok("yes")
validator("no")   // -> Error("must accept terms")
pub fn nested(
  field: String,
  value: a,
  validator_fn: fn(a) -> #(b, List(FieldError)),
  next: fn(b) -> #(c, List(FieldError)),
) -> #(c, List(FieldError))

Run a sub-validator function, prefixing error paths with the field name.

use address <- sift.nested("address", input.address, validate_address)
// errors get paths like ["address", "zip"]
pub fn not(
  validator: fn(a) -> Result(a, String),
  msg: String,
) -> fn(a) -> Result(a, String)

Invert a validator — fail if it passes, pass if it fails.

let not_admin = sift.not(s.one_of(["admin"], ""), "cannot be admin")
pub fn ok(value: a) -> #(a, List(FieldError))

Wrap a final value into a Validated tuple with no errors

pub fn or(
  v1: fn(a) -> Result(a, String),
  v2: fn(a) -> Result(a, String),
) -> fn(a) -> Result(a, String)

Pass if either validator succeeds (try v1 first, then v2).

let validator = s.email("invalid") |> sift.or(s.url("invalid"))
pub fn refine(
  validated: #(a, List(FieldError)),
  field: String,
  check: fn(a) -> Result(a, String),
) -> #(a, List(FieldError))

Post-assembly whole-object check. Runs on a Validated(a) produced by ok(...), useful for cross-field constraints expressed in terms of the final struct.

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 validate(
  validated: #(a, List(FieldError)),
) -> Result(a, List(FieldError))

Convert a Validated(a) to Result(a, List(FieldError)).

sift.ok(User(name: "Jo", email: "jo@example.com"))
|> sift.validate
// -> Ok(User(name: "Jo", email: "jo@example.com"))
pub fn when(
  condition: Bool,
  validator: fn(a) -> Result(a, String),
) -> fn(a) -> Result(a, String)

Conditional validator — runs the validator only when condition is True.

use state <- sift.check("state", input.state,
  sift.when(country == "US", s.non_empty("required")))
Search Document