Two hook points complement the declarative derives: pipeline:
validator: {Mod, :fn}on a field — runs per-value, before derives.main_validator: {Mod, :fn}in the section — runs once after every field validates, gets the full attribute map.
Per-field validator
field :age, :integer, validator: {MyApp.Checks, :positive_only}
defmodule MyApp.Checks do
def positive_only(:age, v) when is_integer(v) and v > 0, do: {:ok, :age, v}
def positive_only(:age, _), do: {:error, :age, "must be positive"}
endReturn shape:
| Return | Effect |
|---|---|
{:ok, name, new_value} | Replace value with new_value, continue. |
{:error, name, message} | Emit %{field: name, action: :validator, message: message}. |
| anything else | Treated as {:ok, value} (no change). |
Compile-time verifier VerifyValidatorMFA rejects the module if the MFA
doesn't exist.
Section-level main_validator
guardedstruct main_validator: {MyApp.Checks, :ensure_consistent} do
field :a, :string
field :b, :string
end
def ensure_consistent(%{a: a, b: b} = attrs) do
if a == b, do: {:ok, attrs}, else: {:error, %{field: :__root__, action: :main_validator, message: "a must equal b"}}
endReturn shape:
| Return | Effect |
|---|---|
{:ok, attrs} | Use the (possibly transformed) map for the rest of the pipeline. |
{:error, errs} (list or single map) | Emit error(s). Single maps are wrapped in a list. |
| anything else | Treated as {:ok, attrs}. |
Caller-module fallback (no opts needed)
If you don't pass validator: / main_validator: but the module itself
defines def validator(field, value) or def main_validator(attrs), the
runtime uses those automatically (legacy 0.0.x compat).
Compile-time-baked flags (__guarded_has_validator__/0,
__guarded_has_main_validator__/0) make this a zero-cost check — no
function_exported? at runtime.
Notes
- Use
validator:for shape / business rules that can't be expressed as a built-invalidate(_)op. For composable type/length/format work, prefer declarative derives — they survive Ash atomic mode and the standaloneValidate.run/2API. main_validatorruns in non-atomic Ash paths only when no other field triggers{:not_atomic, _}. In pure standalone mode it always runs.