Current section
Files
Jump to
Current section
Files
usage-rules/validate.md
# `Funx.Validate` Usage Rules
## LLM Functional Programming Foundation
**Key Concepts for LLMs:**
**Validator**: A function that checks data and returns either success or accumulated errors
- **Type signature**: `validate(value, opts, env) -> Either.t(ValidationError.t(), value) | :ok | {:ok, value} | {:error, ValidationError.t()}`
- **Purpose**: Enable composable, declarative data validation with error accumulation
- **Mathematical foundation**: Applicative functor for parallel error collection
- **Composition**: Validators compose via optics-based field projection
**Optics-First Design**: Validators use optics (Lens, Prism, Traversal) for field projection
- **Prism by default**: `at :key` lowers to `Prism.key(:key)` - fields are optional
- **Required for presence**: Only `Required` validator runs on `Nothing`
- **Lens for structure**: Use `Lens.key(:key)` when key must exist (raises `KeyError`)
- **Traversal for relationships**: Validate across multiple related fields
**Applicative Error Accumulation**: All validators run, all errors collected
- **No short-circuiting**: Every validator executes regardless of earlier failures
- **Better UX**: Users see all validation errors at once
- **Sequential mode**: Default, uses Either for monadic composition
- **Parallel mode**: Explicit applicative execution via `mode: :parallel`
**Identity Preservation**: Validation returns original structure unchanged on success
- **Value transformation allowed**: Validators may normalize the focused value for downstream validators
- **Structure preservation**: Extra fields are preserved
- **Original structure returned**: Successful validation returns the original input structure, not the transformed focused values
- **Empty validation**: `validate do end` always returns `Right(value)`
## LLM Decision Guide: When to Use Validate
**✅ Use Validate when:**
- Need declarative validation rules for data structures
- Want all errors at once (not just first failure)
- Building reusable, composable validation logic
- Validating nested structures with complex field access
- Need context-dependent validation (environment passing)
- Need validator-local normalization before later validators run
- User says: "validate", "check fields", "validation errors", "form validation"
**❌ Don't use Validate when:**
- Simple boolean checks (use Predicate instead)
- Single validation that returns boolean
- Need to rewrite the outer structure as part of validation
- Performance is absolutely critical (slight overhead from optics)
**⚡ Validate vs. Predicate Decision:**
- **Validate**: Returns `Either.t(ValidationError.t(), value)`, accumulates all errors
- **Predicate**: Returns `boolean`, short-circuits on first false
- **Rule**: Use Validate when you need error messages, Predicate when you need boolean
**⚙️ Mode Choice Guide:**
- **Sequential (default)**: Standard mode, monadic composition
- **Parallel**: Explicit applicative, use when order independence matters
## LLM Context Clues
**User language → Validate patterns:**
- "validate user input" → Basic field validation with `at`
- "show all errors" → Applicative error accumulation (default behavior)
- "required field" → `at :field, Required`
- "optional field" → `at :field, Validator` (Prism by default)
- "nested validation" → List path syntax `at [:a, :b, :c], Validator`
- "validate relationship between fields" → Traversal with `Traversal.combine`
- "context-dependent validation" → Environment passing with `env` option
- "compose validators" → Nested validators in `at` clauses
- "whole-structure validation" → Root validators without `at`
## Quick Reference
- **Core concepts**: Optics-based field projection, applicative error accumulation
- **Main macro**: `validate do ... end` with optional `mode: :parallel`
- **Field projection**: `at :field, Validator` (Prism), `at Lens.key(:field), V` (Lens)
- **Multiple validators**: `at :field, [V1, V2]` or `at :field, [Required, {MinLength, min: 3}]`
- **Nested paths**: `at [:a, :b, :c], Validator` (converts to `Prism.path`)
- **Root validators**: Bare validator module runs on entire structure
- **Environment**: `Either.validate(data, validator, env: %{key: value})`
- **Supported validator returns**: `:ok`, `{:ok, value}`, and `{:error, ValidationError.t()}` normalize to `Either`
## Overview
`Funx.Validate` provides a declarative DSL for building composable validators. The DSL uses optics for field projection, accumulates all errors applicatively, and returns the original structure unchanged on success.
Validators may transform the focused value they receive, and later validators in the same chain see that transformed value. Even so, the DSL preserves the outer input structure on success.
The module follows an optics-first design where `at :key` defaults to `Prism.key(:key)`, making fields optional by default. Use `Required` for presence validation or explicit `Lens.key(:key)` for structural requirements.
## DSL Syntax
### Basic Structure
```elixir
use Funx.Validate
validation =
validate do
at :name, Required
at :email, [Required, Email]
at :age, Positive
end
Either.validate(%{name: "Alice", email: "alice@example.com", age: 30}, validation)
# => %Right{right: %{name: "Alice", email: "alice@example.com", age: 30}}
```
### Projection Types
```elixir
# Atom (converts to Prism.key - optional field)
at :email, Email
# List path (converts to Prism.path - nested optional)
at [:user, :profile, :name], Required
# Explicit Prism (optional field)
at Prism.key(:age), Positive
# Explicit Lens (required field - raises KeyError if missing)
at Lens.key(:name), Required
# Traversal (multiple foci for relationship validation)
at Traversal.combine([Lens.key(:start_date), Lens.key(:end_date)]), DateRange
```
### Validator Forms
```elixir
# Module alias
at :name, Required
# Tuple with options
at :name, {MinLength, min: 3}
# List of validators
at :email, [Required, Email]
# Combined
at :name, [Required, {MinLength, min: 3}]
# Function (arity-2)
at :price, fn value, _opts -> Either.right(value) end
# Function (arity-3 with env)
at :price, fn value, _opts, env -> Either.right(value) end
# Additional supported return forms
at :price, fn _value, _opts, _env -> :ok end
at :price, fn value, _opts, _env -> {:ok, value} end
at :price, fn _value, _opts, _env -> {:error, ValidationError.new("invalid")} end
# Composable validator (previously defined)
item_val = validate do
at :name, Required
end
at :item, item_val
```
### Root Validators
```elixir
# Root validator runs on entire structure
validate do
HasContactMethod # Behaviour module validating whole structure
at :name, Required
end
```
### Execution Modes
```elixir
# Sequential mode (default)
validate do
at :name, Required
end
# Parallel mode (explicit applicative)
validate mode: :parallel do
at :name, Required
at :email, Email
end
```
## DSL Examples
### Basic Field Validation
```elixir
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Validator.{Required, Email, MinLength, Positive}
user_validation =
validate do
at :name, [Required, {MinLength, min: 3}]
at :email, [Required, Email]
at :age, Positive
end
# Success
Either.validate(%{name: "Alice", email: "alice@example.com", age: 30}, user_validation)
# => %Right{right: %{name: "Alice", email: "alice@example.com", age: 30}}
# All errors accumulated
Either.validate(%{name: "", email: "bad", age: -5}, user_validation)
# => %Left{left: %ValidationError{errors: ["is required", "must be at least 3 characters", "must be a valid email", "must be positive"]}}
```
### Optional vs Required Fields
```elixir
# Prism (default): Missing field is OK, validator skips Nothing
optional_age =
validate do
at :age, Positive # at :age uses Prism.key(:age)
end
Either.validate(%{name: "Alice"}, optional_age)
# => %Right{right: %{name: "Alice"}} # Missing :age is fine
# Required: Must be present
required_age =
validate do
at :age, [Required, Positive]
end
Either.validate(%{name: "Alice"}, required_age)
# => %Left{left: %ValidationError{errors: ["is required"]}}
```
### Nested Path Validation
```elixir
nested_validation =
validate do
at [:user, :profile, :name], Required
at [:user, :profile, :age], Positive
end
data = %{user: %{profile: %{name: "Alice", age: 30}}}
Either.validate(data, nested_validation)
# => %Right{right: %{user: %{profile: %{name: "Alice", age: 30}}}}
```
### Environment Passing
```elixir
defmodule UniqueEmail do
@behaviour Funx.Validate.Behaviour
alias Funx.Monad.Maybe.Nothing
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
def validate(email, _opts, env) do
existing = Map.get(env, :existing_emails, [])
if email in existing do
Either.left(ValidationError.new("email already taken"))
else
Either.right(email)
end
end
end
validation =
validate do
at :email, [Required, Email, UniqueEmail]
end
env = %{existing_emails: ["taken@example.com"]}
Either.validate(%{email: "new@example.com"}, validation, env: env)
# => %Right{right: %{email: "new@example.com"}}
Either.validate(%{email: "taken@example.com"}, validation, env: env)
# => %Left{left: %ValidationError{errors: ["email already taken"]}}
```
### Return Normalization
```elixir
defmodule LegacyPositive do
@behaviour Funx.Validate.Behaviour
alias Funx.Errors.ValidationError
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(value, _opts, _env) when is_number(value) and value > 0, do: :ok
def validate(_value, _opts, _env), do: {:error, ValidationError.new("must be positive")}
end
validation =
validate do
at :score, LegacyPositive
end
Either.validate(%{score: 10}, validation)
# => %Right{right: %{score: 10}}
Either.validate(%{score: -1}, validation)
# => %Left{left: %ValidationError{errors: ["must be positive"]}}
```
### Traversal for Relationship Validation
```elixir
defmodule DateRange do
@behaviour Funx.Validate.Behaviour
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate([start_date, end_date], _opts, _env) do
if Date.compare(start_date, end_date) == :lt do
Either.right([start_date, end_date])
else
Either.left(ValidationError.new("start_date must be before end_date"))
end
end
end
booking_validation =
validate do
at Traversal.combine([Lens.key(:start_date), Lens.key(:end_date)]), DateRange
end
Either.validate(%{start_date: ~D[2024-01-01], end_date: ~D[2024-01-31]}, booking_validation)
# => %Right{right: %{start_date: ~D[2024-01-01], end_date: ~D[2024-01-31]}}
```
### Composable Validators
```elixir
item_validation =
validate do
at :name, Required
at :price, [Required, Positive]
end
order_validation =
validate do
at :item, item_validation
at :quantity, Positive
end
Either.validate(%{item: %{name: "Widget", price: 10}, quantity: 5}, order_validation)
# => %Right{right: %{item: %{name: "Widget", price: 10}, quantity: 5}}
```
### Root Validators
```elixir
defmodule HasContactMethod do
@behaviour Funx.Validate.Behaviour
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%{email: email} = value, _opts, _env) when is_binary(email) and email != "",
do: Either.right(value)
def validate(%{phone: phone} = value, _opts, _env) when is_binary(phone) and phone != "",
do: Either.right(value)
def validate(_, _opts, _env),
do: Either.left(ValidationError.new("must have email or phone"))
end
validation =
validate do
HasContactMethod
at :name, Required
end
Either.validate(%{name: "Alice", email: "alice@example.com"}, validation)
# => %Right{right: %{name: "Alice", email: "alice@example.com"}}
```
## Built-in Validators
### Type Validators
| Validator | Purpose | Options |
|-----------|---------|---------|
| `String` | Must be a string (binary) | None |
| `Integer` | Must be an integer | None |
| `Float` | Must be a float | None |
| `Number` | Must be a number (integer or float) | None |
| `Boolean` | Must be a boolean (true or false) | None |
| `Atom` | Must be an atom | None |
| `List` | Must be a list | None |
| `Map` | Must be a map | None |
### Presence and String Validators
| Validator | Purpose | Options |
|-----------|---------|---------|
| `Required` | Presence validation | None |
| `Email` | Email format | None |
| `MinLength` | Minimum string length | `min: integer` |
| `MaxLength` | Maximum string length | `max: integer` |
| `Pattern` | Regex pattern match | `pattern: regex` |
### Numeric Validators
| Validator | Purpose | Options |
|-----------|---------|---------|
| `Positive` | Number > 0 | None |
| `Negative` | Number < 0 | None |
| `GreaterThan` | Number > value | `value: number` |
| `LessThan` | Number < value | `value: number` |
| `GreaterThanOrEq` | Number >= value | `value: number` |
| `LessThanOrEq` | Number <= value | `value: number` |
| `Range` | Value in range | `min: number, max: number` |
### Collection and Logic Validators
| Validator | Purpose | Options |
|-----------|---------|---------|
| `In` | Value in set | `values: list` |
| `NotIn` | Value not in set | `values: list` |
| `Each` | Validate each item | `validator: validator` |
| `Confirmation` | Matches another field | `field: atom` |
| `Not` | Negate validator | `validator: validator` |
## Creating Custom Validators
### Behaviour Module
```elixir
defmodule MyValidator do
@behaviour Funx.Validate.Behaviour
alias Funx.Errors.ValidationError
alias Funx.Monad.Either
alias Funx.Monad.Maybe.Nothing
# Handle 2-arity calls
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(value, opts, env)
# Skip Nothing (Prism returned Nothing for missing key)
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
# Actual validation logic
def validate(value, opts, _env) do
if valid?(value, opts) do
Either.right(value)
else
Either.left(ValidationError.new("validation failed"))
end
end
defp valid?(value, opts), do: # ... validation logic
end
```
### Function Validator
```elixir
# Arity-2 function
my_validator = fn value, _opts ->
if valid?(value) do
Either.right(value)
else
Either.left(ValidationError.new("validation failed"))
end
end
# Arity-3 function (with environment)
env_validator = fn value, _opts, env ->
max = Map.get(env, :max_value, 100)
if value <= max do
Either.right(value)
else
Either.left(ValidationError.new("exceeds maximum"))
end
end
```
## Correct Usage Patterns
### Field Validation Pipeline
```elixir
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Validator.{Required, Email, MinLength, Positive}
# Define validation
user_validation =
validate do
at :name, [Required, {MinLength, min: 2}]
at :email, [Required, Email]
at :age, Positive
end
# Use in processing pipeline
def process_user(params) do
params
|> Either.validate(user_validation)
|> Either.map(&create_user/1)
|> Either.map_left(&format_errors/1)
end
```
### Nested Structure Validation
```elixir
# Validate deeply nested structures
address_validation =
validate do
at :street, Required
at :city, Required
at :zip, [Required, {Pattern, pattern: ~r/^\d{5}$/}]
end
user_validation =
validate do
at :name, Required
at :address, address_validation
end
```
### Context-Dependent Validation
```elixir
# Pass context via environment
validation =
validate do
at :email, UniqueEmail # Checks against env[:existing_emails]
at :role, ValidRole # Checks against env[:allowed_roles]
end
env = %{
existing_emails: ["taken@example.com"],
allowed_roles: [:user, :admin]
}
Either.validate(params, validation, env: env)
```
## Compositional Guidelines
Follow these patterns when generating or working with validators:
- **Use descriptive validators**: Name custom validators clearly
- **Compose from simple parts**: Build complex validation from simple validators
- **Handle Nothing**: Custom validators should handle `Nothing` from Prism
- **Accumulate errors**: Return `ValidationError.new/1` for failures
- **Preserve identity**: Return `Either.right(value)` on success, not transformed value
- **Use appropriate optics**: Prism for optional, Lens for required, Traversal for relationships
## Stability Contract
Ensure that validators maintain:
- **Identity preservation**: Return original value unchanged on success
- **Error accumulation**: All validators run, all errors collected
- **Nothing handling**: Handle `Nothing` from Prism projections
- **Type consistency**: Validators should accept the projected value type
- **Environment propagation**: Pass env to validators that need context
## Anti-Patterns
Avoid generating or emitting:
- **Transforming values**: Validators should check, not transform
- **Short-circuiting manually**: Let the DSL handle error accumulation
- **Ignoring Nothing**: Always handle `Nothing` in custom validators
- **Raising exceptions**: Return `Either.left` instead of raising
- **Side effects in validators**: Keep validation pure
- **Using Lens for optional fields**: Use Prism (default) for optional fields
## Good Patterns
Encourage completions like:
```elixir
# Proper custom validator with Nothing handling
defmodule Positive do
@behaviour Funx.Validate.Behaviour
alias Funx.Monad.Maybe.Nothing
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
def validate(value, _opts, _env) when is_number(value) and value > 0,
do: Either.right(value)
def validate(_, _opts, _env),
do: Either.left(ValidationError.new("must be positive"))
end
```
```elixir
# Composable validation structure
base_validation =
validate do
at :name, [Required, {MinLength, min: 2}]
end
extended_validation =
validate do
base_validation
at :email, [Required, Email]
end
```
```elixir
# Environment-aware validation
validation =
validate do
at :username, [Required, UniqueUsername]
end
# Process with context
def validate_user(params, existing_usernames) do
Either.validate(params, validation, env: %{existing_usernames: existing_usernames})
end
```
## LLM Code Templates
### Basic Form Validation Template
```elixir
defmodule UserValidation do
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Validator.{Required, Email, MinLength, Positive}
def user_validation do
validate do
at :name, [Required, {MinLength, min: 2}]
at :email, [Required, Email]
at :age, Positive
end
end
def validate_user(params) do
Either.validate(params, user_validation())
end
def validate_user_with_context(params, env) do
Either.validate(params, user_validation(), env: env)
end
end
```
### Nested Validation Template
```elixir
defmodule OrderValidation do
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Validator.{Required, Positive}
def item_validation do
validate do
at :name, Required
at :price, [Required, Positive]
at :quantity, [Required, Positive]
end
end
def order_validation do
validate do
at :customer_id, Required
at :items, {Each, validator: item_validation()}
at :total, Positive
end
end
def validate_order(order) do
Either.validate(order, order_validation())
end
end
```
### Custom Validator Template
```elixir
defmodule CustomValidators do
@moduledoc "Custom validators for domain-specific validation"
alias Funx.Errors.ValidationError
alias Funx.Monad.Either
alias Funx.Monad.Maybe.Nothing
defmodule UniqueEmail do
@behaviour Funx.Validate.Behaviour
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
def validate(email, _opts, env) do
existing = Map.get(env, :existing_emails, [])
if email in existing do
Either.left(ValidationError.new("email already taken"))
else
Either.right(email)
end
end
end
defmodule ValidDateRange do
@behaviour Funx.Validate.Behaviour
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
def validate([start_date, end_date], _opts, _env) do
if Date.compare(start_date, end_date) == :lt do
Either.right([start_date, end_date])
else
Either.left(ValidationError.new("start date must be before end date"))
end
end
end
end
```
### API Request Validation Template
```elixir
defmodule APIValidation do
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Monad.Either.{Left, Right}
alias Funx.Validator.{Required, Email, MinLength, In}
def create_user_validation do
validate do
at :name, [Required, {MinLength, min: 2}]
at :email, [Required, Email]
at :role, {In, values: [:user, :admin, :moderator]}
end
end
def validate_request(params) do
case Either.validate(params, create_user_validation()) do
%Right{right: validated} ->
{:ok, validated}
%Left{left: %{errors: errors}} ->
{:error, %{validation_errors: errors}}
end
end
end
```
## LLM Testing Guidance
### Test Basic Validation
```elixir
defmodule ValidationTest do
use ExUnit.Case
use Funx.Validate
alias Funx.Monad.Either
alias Funx.Monad.Either.{Left, Right}
alias Funx.Errors.ValidationError
alias Funx.Validator.{Required, Email, Positive}
test "validates valid data" do
validation =
validate do
at :name, Required
at :email, Email
end
result = Either.validate(%{name: "Alice", email: "alice@example.com"}, validation)
assert %Right{right: %{name: "Alice", email: "alice@example.com"}} = result
end
test "accumulates all errors" do
validation =
validate do
at :name, Required
at :email, [Required, Email]
at :age, Positive
end
result = Either.validate(%{name: "", email: "bad", age: -5}, validation)
assert %Left{left: %ValidationError{errors: errors}} = result
assert length(errors) >= 3
end
test "preserves original structure on success" do
validation =
validate do
at :name, Required
end
input = %{name: "Alice", extra: "field"}
result = Either.validate(input, validation)
assert %Right{right: ^input} = result
end
end
```
### Test Optional Fields
```elixir
test "optional fields skip validation when missing" do
validation =
validate do
at :age, Positive # at :age uses Prism - optional
end
# Missing :age is fine
result = Either.validate(%{name: "Alice"}, validation)
assert %Right{} = result
end
test "optional fields validate when present" do
validation =
validate do
at :age, Positive
end
# Present but invalid
result = Either.validate(%{age: -5}, validation)
assert %Left{} = result
end
```
### Test Environment Passing
```elixir
test "passes environment to validators" do
defmodule TestUniqueEmail do
@behaviour Funx.Validate.Behaviour
alias Funx.Monad.Maybe.Nothing
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = v, _, _), do: Either.right(v)
def validate(email, _opts, env) do
if email in Map.get(env, :existing, []) do
Either.left(ValidationError.new("taken"))
else
Either.right(email)
end
end
end
validation =
validate do
at :email, TestUniqueEmail
end
env = %{existing: ["taken@example.com"]}
assert %Right{} = Either.validate(%{email: "new@example.com"}, validation, env: env)
assert %Left{} = Either.validate(%{email: "taken@example.com"}, validation, env: env)
end
```
## LLM Common Mistakes to Avoid
### ❌ Don't Forget to Handle Nothing
```elixir
# ❌ Wrong: ignores Nothing from Prism
defmodule BadValidator do
@behaviour Funx.Validate.Behaviour
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(value, _opts, _env) do
if value > 0, do: Either.right(value), else: Either.left(ValidationError.new("error"))
end
end
# ✅ Correct: handles Nothing
defmodule GoodValidator do
@behaviour Funx.Validate.Behaviour
alias Funx.Monad.Maybe.Nothing
def validate(value, opts) when is_list(opts), do: validate(value, opts, %{})
@impl true
def validate(%Nothing{} = value, _opts, _env), do: Either.right(value)
def validate(value, _opts, _env) do
if value > 0, do: Either.right(value), else: Either.left(ValidationError.new("error"))
end
end
```
### ❌ Don't Use Lens for Optional Fields
```elixir
# ❌ Wrong: Lens raises KeyError for missing fields
validation =
validate do
at Lens.key(:age), Positive # Will raise if :age is missing!
end
# ✅ Correct: use default Prism (via atom) for optional fields
validation =
validate do
at :age, Positive # at :age uses Prism.key(:age) - safe for missing
end
```
### ❌ Don't Assume Required is Automatic
```elixir
# ❌ Wrong: assumes field must be present
validation =
validate do
at :email, Email # Missing :email will pass! (Prism returns Nothing, Email skips)
end
# ✅ Correct: use Required for presence validation
validation =
validate do
at :email, [Required, Email] # Required catches Nothing
end
```
### ❌ Don't Transform Values in Validators
```elixir
# ❌ Wrong: transforms value
defmodule TransformingValidator do
@behaviour Funx.Validate.Behaviour
@impl true
def validate(email, _opts, _env) do
Either.right(String.downcase(email)) # Don't transform!
end
end
# ✅ Correct: return original value
defmodule CheckingValidator do
@behaviour Funx.Validate.Behaviour
@impl true
def validate(email, _opts, _env) do
if valid_email?(email) do
Either.right(email) # Return original value
else
Either.left(ValidationError.new("invalid"))
end
end
end
```
## Summary
`Funx.Validate` provides declarative, composable data validation with applicative error accumulation. It uses optics for field projection and returns the original structure unchanged on success.
**Key capabilities:**
- **Optics-first design**: Prism by default, Lens for structure, Traversal for relationships
- **Applicative accumulation**: All validators run, all errors collected
- **Identity preservation**: Returns original structure unchanged on success
- **Composable validators**: Build complex validation from simple parts
- **Environment passing**: Context-dependent validation via `env` option
**Core patterns:**
- Use `at :field, Validator` for optional fields (Prism)
- Use `at :field, [Required, V]` for required fields
- Use `at Lens.key(:field), V` only when key must structurally exist
- Use `at [:a, :b], V` for nested paths
- Use `at Traversal.combine([...]), V` for relationship validation
**Integration points:**
- **Either**: `Either.validate(data, validator, opts)` executes validation
- **ValidationError**: Accumulated errors via `ValidationError.new/1`
- **Optics**: Lens, Prism, Traversal for field projection
- **Custom validators**: Implement `Funx.Validate.Behaviour`
**Canon**: Project with optics, validate with behaviours, accumulate errors applicatively, preserve identity on success.