Mooncore.Validate
(mooncore v0.2.5)
Copy Markdown
A composable, pipeline-friendly data validation module.
Field Keys
Schema field keys determine which key is looked up in the data map — the key type is the contract, no automatic conversion happens:
- Atom key
:title— matches%{title: "..."}(atom-keyed maps) - String key
"title"— matches%{"title" => "..."}(HTTP/WebSocket params) - Path
["address", "city"]— matches nested%{"address" => %{"city" => "..."}}
Usage
# Atom-keyed data (internal Elixir calls)
result =
%{name: "Alice", age: 20, email: "alice@example.com", tags: ["elixir", "phoenix"]}
|> Validate.rule(:name, [:required, :string, {:min_length, 2}])
|> Validate.rule(:age, [:required, :integer, :positive, {:between, 18, 120}])
|> Validate.rule(:email, [:required, :email])
|> Validate.rule(:tags, [{:list_of, :string}, {:max_items, 10}])
|> Validate.run()
# String-keyed data (HTTP/WebSocket params)
result =
%{"name" => "Alice", "age" => 20}
|> Validate.rule("name", [:required, :string, {:min_length, 2}])
|> Validate.rule("age", [:required, :integer, {:between, 18, 120}])
|> Validate.run()
case result do
{:ok, data} -> data # clean map, metadata key stripped
{:error, errors} -> errors # %{field => [message, ...]} — serializes directly to JSON
endError example:
{:error, %{name: ["is required"], age: ["must be positive", "must be between 18 and 120"]}}Schemas
Pre-register a reusable field->rules map with build_schema/1:
@user_schema Validate.build_schema([
{:name, [:required, :string]},
{"email", [:required, :email]},
{["address", "city"], [:required, :string]},
{:age, [:required, :integer, {:between, 18, 120}]}
])
Validate.run_schema(data, @user_schema)Note:
build_schema/1accepts a plain list of{field, rules}tuples, not a keyword list, so that string and path keys can be used alongside atom keys. Atom-key-only schemas can still use the keyword shorthand:Validate.build_schema(name: [:required, :string], age: [:required, :integer])
Available Rules
Presence
:required— field must be present and non-nil
Type checks (nil passes — absence is :required's concern)
:string— must be a binary:integer— must be an integer:float— must be a float:number— must be an integer or float:boolean— must be a boolean
Numeric
{:min, n}— value >= n{:max, n}— value <= n{:between, min, max}— min <= value <= max (inclusive):positive— value > 0:non_negative— value >= 0{:multiple_of, n}— value is divisible by n
String length
{:min_length, n}— string length >= n{:max_length, n}— string length <= n{:length, n}— string length == n
String format
:email— basic structural email check:uuid— UUID v4 format:url— http/https URL:iso8601— datetime string (uses DateTime.from_iso8601/1):date— date string YYYY-MM-DD{:regex, pattern}— matches a compiled Regex:trimmed— no leading or trailing whitespace{:starts_with, prefix}— string starts with prefix{:ends_with, suffix}— string ends with suffix
Membership
{:in, list}— value is a member of list{:not_in, list}— value is not a member of list
Collections
{:list_of, rule}— every list element passes rule; also guards list type{:min_items, n}— list has at least n elements{:max_items, n}— list has at most n elements{:nested, schema}— validates a nested map; schema is a list of{field, rules}tuples
Cross-field
{:equal_to, other_field}— value == value of other_field{:greater_than, other_field}— numeric value > numeric value of other_field{:only_one_of, other_field}— exactly one of the two fields is present/non-nil{:forbidden_with, other_field}— field must be absent when other_field is present{:required_if, other_field, val}— field is required when other_field == val{:required_with, other_field}— field is required when other_field is present
Custom
{:fn, func}— func.(value) returns :ok | {:ok, v} | {:error, msg} | boolean{:fn, func, error_message}— func.(value) returns truthy; uses error_message on failure
Summary
Functions
Builds a reusable schema from a list of {field, rules} tuples.
Atom keys, string keys, and path keys are all supported
Registers a list of rules for field on data. Returns the updated data map.
Rules are applied in declaration order. Short-circuits within a field on first
failure; collects errors across all fields.
Runs all registered validations. Returns {:ok, clean_data} or
{:error, [{field, message}, ...]}.
Same as run/1 but calls success_fn.(clean_data) on success.
Runs a pre-built schema against data directly, without piping through validate/3.
Types
@type result() :: {:ok, validation_data()} | {:error, errors()}
@type rule() :: :required | :string | :integer | :float | :number | :boolean | {:min, number()} | {:max, number()} | {:between, number(), number()} | :positive | :non_negative | {:multiple_of, number()} | {:min_length, non_neg_integer()} | {:max_length, non_neg_integer()} | {:length, non_neg_integer()} | :email | :uuid | :url | :iso8601 | :date | {:regex, Regex.t()} | :trimmed | {:starts_with, String.t()} | {:ends_with, String.t()} | {:in, list()} | {:not_in, list()} | {:list_of, rule()} | {:min_items, non_neg_integer()} | {:max_items, non_neg_integer()} | {:nested, [{field(), [rule()]}]} | {:equal_to, field()} | {:greater_than, field()} | {:only_one_of, field()} | {:forbidden_with, field()} | {:required_if, field(), any()} | {:required_with, field()} | {:fn, (any() -> boolean() | {:ok, any()} | {:error, String.t()})} | {:fn, (any() -> boolean()), String.t()}
@type validation_data() :: map()
Functions
Builds a reusable schema from a list of {field, rules} tuples.
Atom keys, string keys, and path keys are all supported:
@create_user_schema Validate.build_schema([
{:name, [:required, :string, {:min_length, 2}]},
{"email", [:required, :email]},
{["address", "city"], [:required, :string]},
{:age, [:integer, {:between, 18, 120}]}
])Atom-only schemas can use the keyword shorthand:
Validate.build_schema(name: [:required, :string], age: [:required, :integer])
@spec rule(validation_data(), field(), [rule()]) :: validation_data()
Registers a list of rules for field on data. Returns the updated data map.
Rules are applied in declaration order. Short-circuits within a field on first
failure; collects errors across all fields.
field can be an atom, a string, or a list of strings (path):
data
|> Validate.rule(:title, [:required, :string]) # atom key
|> Validate.rule("title", [:required, :string]) # string key
|> Validate.rule(["address", "city"], [:required, :string]) # nested path
@spec run(validation_data()) :: result()
Runs all registered validations. Returns {:ok, clean_data} or
{:error, [{field, message}, ...]}.
@spec run(validation_data(), (validation_data() -> any())) :: any() | result()
Same as run/1 but calls success_fn.(clean_data) on success.
@spec run_schema(validation_data(), schema()) :: result()
Runs a pre-built schema against data directly, without piping through validate/3.