Packages

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

Current section

Files

Jump to
sift src sift.erl
Raw

src/sift.erl

-module(sift).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/sift.gleam").
-export([check/4, nested/4, ok/1, validate/1, 'and'/2, each/4, 'or'/2, 'not'/2, equals/2, check_all/4, 'when'/2, check_optional/4, check_parse/6, check_each/4, check2/5, refine/3, custom/1]).
-export_type([field_error/1]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Core validation functions — check fields, accumulate errors, compose validators.\n"
"\n"
" Build a validator for a struct by chaining `check` calls with `use`,\n"
" then finish with `ok` and convert to a `Result` with `validate`. Every\n"
" field runs, so a single call returns every error at once — not just the\n"
" first.\n"
"\n"
" ```gleam\n"
" import sift\n"
" import sift/int as i\n"
" import sift/string as s\n"
"\n"
" pub type User { User(name: String, email: String, age: Int) }\n"
"\n"
" pub fn validate_user(input: User) -> Result(User, List(sift.FieldError(String))) {\n"
" sift.validate({\n"
" use name <- sift.check(\"name\", input.name, s.non_empty(\"required\"))\n"
" use email <- sift.check(\"email\", input.email, s.email(\"invalid\"))\n"
" use age <- sift.check(\"age\", input.age, i.between(0, 150, \"out of range\"))\n"
" sift.ok(User(name:, email:, age:))\n"
" })\n"
" }\n"
" ```\n"
"\n"
" For nested structs use `nested`, for lists use `each`, for multiple\n"
" constraints on one field use `check_all`, and for conditional\n"
" constraints use `when`. See `example/contacts/` for a full walkthrough.\n"
).
-type field_error(DLN) :: {field_error, list(binary()), DLN}.
-file("src/sift.gleam", 62).
?DOC(
" Run a validator on a field value, accumulate errors, feeds into `use`.\n"
"\n"
" ```gleam\n"
" use name <- sift.check(\"name\", input.name, s.min_length(1, \"required\"))\n"
" use email <- sift.check(\"email\", input.email, s.email(\"invalid\"))\n"
" sift.ok(User(name:, email:))\n"
" ```\n"
).
-spec check(
binary(),
DLW,
fun((DLW) -> {ok, DLW} | {error, DLX}),
fun((DLW) -> {DMA, list(field_error(DLX))})
) -> {DMA, list(field_error(DLX))}.
check(Field, Value, Validator, Next) ->
case Validator(Value) of
{ok, V} ->
Next(V);
{error, Msg} ->
{Result, Errors} = Next(Value),
{Result, [{field_error, [Field], Msg} | Errors]}
end.
-file("src/sift.gleam", 83).
?DOC(
" Run a sub-validator function, prefixing error paths with the field name.\n"
"\n"
" ```gleam\n"
" use address <- sift.nested(\"address\", input.address, validate_address)\n"
" // errors get paths like [\"address\", \"zip\"]\n"
" ```\n"
).
-spec nested(
binary(),
DMF,
fun((DMF) -> {DMG, list(field_error(DMH))}),
fun((DMG) -> {DMK, list(field_error(DMH))})
) -> {DMK, list(field_error(DMH))}.
nested(Field, Value, Validator_fn, Next) ->
{Inner_value, Inner_errors} = Validator_fn(Value),
Prefixed_errors = gleam@list:map(
Inner_errors,
fun(Err) ->
{field_error,
[Field | erlang:element(2, Err)],
erlang:element(3, Err)}
end
),
{Result, Outer_errors} = Next(Inner_value),
{Result, lists:append(Prefixed_errors, Outer_errors)}.
-file("src/sift.gleam", 99).
?DOC(" Wrap a final value into a Validated tuple with no errors\n").
-spec ok(DMP) -> {DMP, list(field_error(any()))}.
ok(Value) ->
{Value, []}.
-file("src/sift.gleam", 110).
?DOC(
" Convert a Validated(a, e) to Result(a, List(FieldError(e))).\n"
"\n"
" ```gleam\n"
" sift.ok(User(name: \"Jo\", email: \"jo@example.com\"))\n"
" |> sift.validate\n"
" // -> Ok(User(name: \"Jo\", email: \"jo@example.com\"))\n"
" ```\n"
).
-spec validate({DMT, list(field_error(DMU))}) -> {ok, DMT} |
{error, list(field_error(DMU))}.
validate(Validated) ->
case Validated of
{Value, []} ->
{ok, Value};
{_, Errors} ->
{error, Errors}
end.
-file("src/sift.gleam", 122).
?DOC(
" Compose two validators — run both, accumulate errors from both.\n"
"\n"
" ```gleam\n"
" let validator = s.min_length(1, \"required\") |> sift.and(s.email(\"invalid\"))\n"
" ```\n"
).
-spec 'and'(
fun((DNB) -> {ok, DNB} | {error, DNC}),
fun((DNB) -> {ok, DNB} | {error, DNC})
) -> fun((DNB) -> {ok, DNB} | {error, DNC}).
'and'(V1, V2) ->
fun(Value) -> case {V1(Value), V2(Value)} of
{{ok, A}, {ok, _}} ->
{ok, A};
{{ok, _}, {error, Msg}} ->
{error, Msg};
{{error, Msg@1}, {ok, _}} ->
{error, Msg@1};
{{error, Msg@2}, _} ->
{error, Msg@2}
end end.
-file("src/sift.gleam", 141).
?DOC(
" Validate every item in a list, accumulating indexed error paths.\n"
" Produces paths like `[\"tags\", \"0\"]`, `[\"tags\", \"1\"]`, etc.\n"
"\n"
" ```gleam\n"
" use tags <- sift.each(\"tags\", input.tags, s.non_empty(\"empty tag\"))\n"
" // invalid items get paths like [\"tags\", \"2\"]\n"
" ```\n"
).
-spec each(
binary(),
list(DNJ),
fun((DNJ) -> {ok, DNJ} | {error, DNL}),
fun((list(DNJ)) -> {DNP, list(field_error(DNL))})
) -> {DNP, list(field_error(DNL))}.
each(Field, Items, Validator, Next) ->
Item_errors = begin
_pipe = Items,
_pipe@1 = gleam@list:index_map(
_pipe,
fun(Item, Idx) -> case Validator(Item) of
{ok, _} ->
[];
{error, Msg} ->
[{field_error,
[Field, erlang:integer_to_binary(Idx)],
Msg}]
end end
),
lists:append(_pipe@1)
end,
{Result, Outer_errors} = Next(Items),
{Result, lists:append(Item_errors, Outer_errors)}.
-file("src/sift.gleam", 167).
?DOC(
" Pass if either validator succeeds (try v1 first, then v2).\n"
"\n"
" ```gleam\n"
" let validator = s.email(\"invalid\") |> sift.or(s.url(\"invalid\"))\n"
" ```\n"
).
-spec 'or'(
fun((DNU) -> {ok, DNU} | {error, DNV}),
fun((DNU) -> {ok, DNU} | {error, DNV})
) -> fun((DNU) -> {ok, DNU} | {error, DNV}).
'or'(V1, V2) ->
fun(Value) -> case V1(Value) of
{ok, V} ->
{ok, V};
{error, _} ->
V2(Value)
end end.
-file("src/sift.gleam", 184).
?DOC(
" Invert a validator — fail if it passes, pass if it fails.\n"
"\n"
" The inverted validator's own error is discarded, so its error type is\n"
" independent of `msg`'s — pass any placeholder you like.\n"
"\n"
" ```gleam\n"
" let not_admin = sift.not(s.one_of([\"admin\"], Nil), \"cannot be admin\")\n"
" ```\n"
).
-spec 'not'(fun((DOC) -> {ok, DOC} | {error, any()}), DOG) -> fun((DOC) -> {ok,
DOC} |
{error, DOG}).
'not'(Validator, Msg) ->
fun(Value) -> case Validator(Value) of
{ok, _} ->
{error, Msg};
{error, _} ->
{ok, Value}
end end.
-file("src/sift.gleam", 200).
?DOC(
" Value must equal the expected value.\n"
"\n"
" ```gleam\n"
" let validator = sift.equals(\"yes\", \"must accept terms\")\n"
" validator(\"yes\") // -> Ok(\"yes\")\n"
" validator(\"no\") // -> Error(\"must accept terms\")\n"
" ```\n"
).
-spec equals(DOJ, DOK) -> fun((DOJ) -> {ok, DOJ} | {error, DOK}).
equals(Expected, Msg) ->
fun(Value) -> case Value =:= Expected of
true ->
{ok, Value};
false ->
{error, Msg}
end end.
-file("src/sift.gleam", 219).
?DOC(
" Run multiple validators on a field, accumulate all errors.\n"
"\n"
" ```gleam\n"
" use name <- sift.check_all(\"name\", input.name, [\n"
" s.non_empty(\"required\"),\n"
" s.min_length(3, \"too short\"),\n"
" s.max_length(100, \"too long\"),\n"
" ])\n"
" sift.ok(name)\n"
" ```\n"
).
-spec check_all(
binary(),
DON,
list(fun((DON) -> {ok, DON} | {error, DOO})),
fun((DON) -> {DOS, list(field_error(DOO))})
) -> {DOS, list(field_error(DOO))}.
check_all(Field, Value, Validators, Next) ->
Field_errors = begin
_pipe = Validators,
gleam@list:filter_map(_pipe, fun(V) -> case V(Value) of
{ok, _} ->
{error, nil};
{error, Msg} ->
{ok, {field_error, [Field], Msg}}
end end)
end,
{Result, Outer_errors} = Next(Value),
{Result, lists:append(Field_errors, Outer_errors)}.
-file("src/sift.gleam", 243).
?DOC(
" Conditional validator — runs the validator only when condition is True.\n"
"\n"
" ```gleam\n"
" use state <- sift.check(\"state\", input.state,\n"
" sift.when(country == \"US\", s.non_empty(\"required\")))\n"
" ```\n"
).
-spec 'when'(boolean(), fun((DOX) -> {ok, DOX} | {error, DOY})) -> fun((DOX) -> {ok,
DOX} |
{error, DOY}).
'when'(Condition, Validator) ->
fun(Value) -> case Condition of
true ->
Validator(Value);
false ->
{ok, Value}
end end.
-file("src/sift.gleam", 259).
?DOC(
" Validate an Option value only when Some, skip when None.\n"
"\n"
" ```gleam\n"
" use nickname <- sift.check_optional(\"nickname\", input.nickname,\n"
" s.min_length(2, \"too short\"))\n"
" sift.ok(User(nickname:))\n"
" ```\n"
).
-spec check_optional(
binary(),
gleam@option:option(DPD),
fun((DPD) -> {ok, DPD} | {error, DPF}),
fun((gleam@option:option(DPD)) -> {DPJ, list(field_error(DPF))})
) -> {DPJ, list(field_error(DPF))}.
check_optional(Field, Value, Validator, Next) ->
case Value of
none ->
Next(none);
{some, V} ->
case Validator(V) of
{ok, _} ->
Next({some, V});
{error, Msg} ->
{Result, Errors} = Next({some, V}),
{Result, [{field_error, [Field], Msg} | Errors]}
end
end.
-file("src/sift.gleam", 288).
?DOC(
" Parse a raw value and feed the result into the chain.\n"
" On success, passes the parsed value to next.\n"
" On failure, records a FieldError and passes the default to next\n"
" so that subsequent fields still validate.\n"
"\n"
" ```gleam\n"
" use age <- sift.check_parse(\"age\", \"42\", int.parse, 0, \"must be a number\")\n"
" use age <- sift.check(\"age\", age, i.min(13, \"must be at least 13\"))\n"
" sift.ok(age)\n"
" ```\n"
).
-spec check_parse(
binary(),
DPO,
fun((DPO) -> {ok, DPP} | {error, any()}),
DPP,
DPT,
fun((DPP) -> {DPU, list(field_error(DPT))})
) -> {DPU, list(field_error(DPT))}.
check_parse(Field, Value, Parser, Default, Msg, Next) ->
case Parser(Value) of
{ok, Parsed} ->
Next(Parsed);
{error, _} ->
{Result, Errors} = Next(Default),
{Result, [{field_error, [Field], Msg} | Errors]}
end.
-file("src/sift.gleam", 316).
?DOC(
" Validate every item in a list with a sub-validator function, prefixing\n"
" error paths with the field name and the item's index.\n"
" Produces paths like `[\"tags\", \"0\", \"name\"]`.\n"
"\n"
" Use this when each item is itself a struct to validate. For a single\n"
" `Validator(a, e)` per item, use `each` instead.\n"
"\n"
" ```gleam\n"
" use tags <- sift.check_each(\"tags\", input.tags, validate_tag)\n"
" // errors get paths like [\"tags\", \"2\", \"name\"]\n"
" ```\n"
).
-spec check_each(
binary(),
list(DPZ),
fun((DPZ) -> {DQB, list(field_error(DQC))}),
fun((list(DQB)) -> {DQG, list(field_error(DQC))})
) -> {DQG, list(field_error(DQC))}.
check_each(Field, Values, Validator_fn, Next) ->
{Validated_values, Item_errors} = begin
_pipe = Values,
_pipe@1 = gleam@list:index_map(
_pipe,
fun(Item, Idx) ->
{Value, Errors} = Validator_fn(Item),
Prefixed = gleam@list:map(
Errors,
fun(Err) ->
{field_error,
[Field,
erlang:integer_to_binary(Idx) |
erlang:element(2, Err)],
erlang:element(3, Err)}
end
),
{Value, Prefixed}
end
),
gleam@list:fold(
_pipe@1,
{[], []},
fun(Acc, Pair) ->
{Values_acc, Errors_acc} = Acc,
{Value@1, Errors@1} = Pair,
{[Value@1 | Values_acc], lists:append(Errors_acc, Errors@1)}
end
)
end,
Validated_values@1 = lists:reverse(Validated_values),
{Result, Outer_errors} = Next(Validated_values@1),
{Result, lists:append(Item_errors, Outer_errors)}.
-file("src/sift.gleam", 358).
?DOC(
" Cross-field validator comparing two already-validated values.\n"
" On success, passes the (possibly transformed) first value to next.\n"
" On failure, records a FieldError under `field` and passes `a` through\n"
" so subsequent checks still run.\n"
"\n"
" ```gleam\n"
" use name <- sift.check(\"name\", input.name, s.non_empty(\"required\"))\n"
" use confirm <- sift.check(\"confirm\", input.confirm, s.non_empty(\"required\"))\n"
" use name <- sift.check2(\"confirm\", name, confirm, fn(a, b) {\n"
" case a == b { True -> Ok(a) False -> Error(\"must match name\") }\n"
" })\n"
" sift.ok(name)\n"
" ```\n"
).
-spec check2(
binary(),
DQL,
DQM,
fun((DQL, DQM) -> {ok, DQL} | {error, DQN}),
fun((DQL) -> {DQQ, list(field_error(DQN))})
) -> {DQQ, list(field_error(DQN))}.
check2(Field, A, B, Validator, Next) ->
case Validator(A, B) of
{ok, V} ->
Next(V);
{error, Msg} ->
{Result, Errors} = Next(A),
{Result, [{field_error, [Field], Msg} | Errors]}
end.
-file("src/sift.gleam", 388).
?DOC(
" Post-assembly whole-object check. Runs on a `Validated(a, e)` produced by\n"
" `ok(...)`, useful for cross-field constraints expressed in terms of the\n"
" final struct.\n"
"\n"
" ```gleam\n"
" sift.ok(Registration(role:, mfa:))\n"
" |> sift.refine(\"mfa\", fn(r) {\n"
" case r.role == \"admin\" && r.mfa == None {\n"
" True -> Error(\"required for admins\")\n"
" False -> Ok(r)\n"
" }\n"
" })\n"
" |> sift.validate\n"
" ```\n"
).
-spec refine(
{DQV, list(field_error(DQW))},
binary(),
fun((DQV) -> {ok, DQV} | {error, DQW})
) -> {DQV, list(field_error(DQW))}.
refine(Validated, Field, Check) ->
{Value, Errors} = Validated,
case Check(Value) of
{ok, V} ->
{V, Errors};
{error, Msg} ->
{Value, lists:append(Errors, [{field_error, [Field], Msg}])}
end.
-file("src/sift.gleam", 415).
?DOC(
" Escape hatch for user-defined checks.\n"
"\n"
" ```gleam\n"
" let even = sift.custom(fn(n: Int) {\n"
" case n % 2 == 0 {\n"
" True -> Ok(n)\n"
" False -> Error(\"must be even\")\n"
" }\n"
" })\n"
" even(4) // -> Ok(4)\n"
" even(3) // -> Error(\"must be even\")\n"
" ```\n"
).
-spec custom(fun((DRD) -> {ok, DRD} | {error, DRE})) -> fun((DRD) -> {ok, DRD} |
{error, DRE}).
custom(F) ->
F.