Mooncore.Validate (mooncore v0.2.4)

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
end

Error 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/1 accepts 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

errors()

@type errors() :: %{required(field()) => [String.t()]}

field()

@type field() :: atom() | String.t() | [String.t()]

result()

@type result() :: {:ok, validation_data()} | {:error, errors()}

rule()

@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()}

schema()

@type schema() :: [{field(), [rule()]}]

validation_data()

@type validation_data() :: map()

Functions

build_schema(fields)

@spec build_schema([{field(), [rule()]}]) :: schema()

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

rule(data, field, rules)

@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

run(data)

@spec run(validation_data()) :: result()

Runs all registered validations. Returns {:ok, clean_data} or {:error, [{field, message}, ...]}.

run(data, success_fn)

@spec run(validation_data(), (validation_data() -> any())) :: any() | result()

Same as run/1 but calls success_fn.(clean_data) on success.

run_schema(data, schema)

@spec run_schema(validation_data(), schema()) :: result()

Runs a pre-built schema against data directly, without piping through validate/3.