sift
Schema validation for Gleam — constraints, error accumulation, and field paths.
All errors are collected in a single pass using the use pattern. No short-circuiting, no missing fields.
gleam add sift
Quick example
import sift
import sift/string as s
import sift/int as i
pub type UserInput {
UserInput(name: String, email: String, age: Int)
}
pub type User {
User(name: String, email: String, age: Int)
}
pub fn validate(input: UserInput) -> Result(User, List(sift.FieldError(String))) {
sift.validate({
use name <- sift.check("name", input.name, s.min_length(1, "required"))
use email <- sift.check("email", input.email, s.email("invalid email"))
use age <- sift.check("age", input.age, i.between(0, 150, "out of range"))
sift.ok(User(name:, email:, age:))
})
}
Invalid input returns all errors at once:
validate(UserInput(name: "", email: "nope", age: -1))
// -> Error([
// FieldError(path: ["name"], error: "required"),
// FieldError(path: ["email"], error: "invalid email"),
// FieldError(path: ["age"], error: "out of range"),
// ])
Upgrading from 0.1.x
Errors used to always be text. Now you choose what an error is — text, an error code, an i18n key, whatever your app needs.
If you want to keep using text, there are two edits.
1. Say String in your return type.
// before
pub fn validate(input: UserInput) -> Result(User, List(sift.FieldError))
// after
pub fn validate(input: UserInput) -> Result(User, List(sift.FieldError(String)))
2. Read .error instead of .message.
// before
errors |> list.map(fn(e) { e.message })
// after
errors |> list.map(fn(e) { e.error })
That is the whole migration. Everything else — your use chains, check,
check_all, nested, ok, validate — stays exactly as it was.
What you get
Errors can now be your own type, and it flows through every validator and every field path:
pub type Problem {
Required
TooShort(min: Int)
NotAnEmail
}
pub fn validate(input: UserInput) -> Result(User, List(sift.FieldError(Problem))) {
sift.validate({
use name <- sift.check("name", input.name, s.non_empty(Required))
use email <- sift.check("email", input.email, s.email(NotAnEmail))
use age <- sift.check("age", input.age, i.min(13, TooShort(13)))
sift.ok(User(name:, email:, age:))
})
}
validate(UserInput(name: "", email: "nope", age: 9))
// -> Error([
// FieldError(path: ["name"], error: Required),
// FieldError(path: ["email"], error: NotAnEmail),
// FieldError(path: ["age"], error: TooShort(13)),
// ])
The built-in validators work with any error type — s.non_empty(Required) is the
same function as s.non_empty("required").
Realistic example
For a full walkthrough — nested structs, optional fields, conditional
validation with when, multiple validators per field with check_all,
and per-item list validation with each — see
example/contacts/.
Features
- Error accumulation — every field is checked, every error is returned
- Your own error type — messages, error codes, or i18n keys; the built-in validators work with all of them
- Field paths — nested structs and lists produce paths like
["address", "zip"]or["tags", "0"] - Multi-validator —
sift.check_allruns multiple validators on one field, collects all errors - Composable —
sift.and,sift.or,sift.notto combine validators - Conditional —
sift.whenruns a validator only when a condition is true - Nested structs —
sift.nestedwith automatic path prefixing - List validation —
sift.eachandsift.check_eachwith indexed paths - Cross-field —
sift.check2in-chain,sift.refinepost-assembly - Optional fields —
sift.check_optional,sift/option.required,sift/option.optional - Form parsing —
sift.check_parsebridges raw strings to typed values - Built-in validators — strings, ints, floats, lists, options
Modules
| Module | Validators |
|---|---|
sift | check, check_all, check_optional, check_parse, check_each, check2, nested, each, refine, ok, validate, and, or, not, when, equals, custom |
sift/string | min_length, max_length, length, non_empty, matches, one_of, starts_with, ends_with, contains, email, url, uuid, numeric, alpha, alphanumeric, trimmed |
sift/int | min, max, between, positive, non_negative, negative, one_of, divisible_by |
sift/float | min, max, between, positive, non_negative |
sift/list | min_length, max_length, non_empty |
sift/option | required, optional |
Further documentation can be found at https://hexdocs.pm/sift.