Advanced validation patterns for dynamic and context-aware schemas.
Validation Modes
Peri supports two validation modes to control how extra fields are handled:
Strict Mode (Default)
By default, Peri operates in strict mode, which filters out any fields not defined in the schema:
schema = %{
name: :string,
age: :integer
}
data = %{name: "John", age: 30, extra: "field"}
{:ok, result} = Peri.validate(schema, data)
# result => %{name: "John", age: 30}Permissive Mode
Permissive mode preserves all fields from the input data, even those not defined in the schema:
{:ok, result} = Peri.validate(schema, data, mode: :permissive)
# result => %{name: "John", age: 30, extra: "field"}Using Permissive Mode with defschema
You can define schemas that always use permissive mode:
defmodule MySchemas do
import Peri
# Strict mode (default)
defschema :user_strict, %{
name: :string,
email: {:required, :string}
}
# Permissive mode
defschema :user_permissive, %{
name: :string,
email: {:required, :string}
}, mode: :permissive
end
data = %{name: "John", email: "john@example.com", role: "admin"}
# Strict mode filters out 'role'
{:ok, strict} = MySchemas.user_strict(data)
# strict => %{name: "John", email: "john@example.com"}
# Permissive mode keeps 'role'
{:ok, permissive} = MySchemas.user_permissive(data)
# permissive => %{name: "John", email: "john@example.com", role: "admin"}Use Cases for Permissive Mode
Permissive mode is useful when:
- Building API gateways that need to forward extra fields
- Implementing progressive validation in layers
- Working with evolving data structures where new fields may be added
- Creating middleware that validates known fields but passes through metadata
Note: Fields not defined in the schema are not validated, they are simply passed through unchanged.
Conditional Validation
Use :cond to validate fields based on runtime conditions.
defmodule UserSchema do
import Peri
defschema :registration, %{
name: {:required, :string},
is_premium: {:required, :boolean},
# Only require payment info if premium
payment_info: {:cond, & &1.is_premium, {:required, :string}, nil}
}
endDependent Validation
Single Field Dependency
Validate a field based on another field's value:
defmodule AuthSchema do
import Peri
defschema :user, %{
password: {:required, :string},
password_confirmation: {:dependent, :password, &match_passwords/2, :string}
}
defp match_passwords(password, password), do: :ok
defp match_passwords(_, _), do: {:error, "passwords must match", []}
endMultiple Field Dependencies
Complex validation based on multiple fields:
defmodule ProfileSchema do
import Peri
defschema :user_profile, %{
contact_type: {:required, {:enum, [:email, :phone, :both]}},
email: {:string, {:regex, ~r/@/}},
phone: :string,
contact_info: {:dependent, &validate_contact/1}
}
defp validate_contact(%{data: %{contact_type: :email}}) do
{:ok, {:required, %{email: {:required, :string}}}}
end
defp validate_contact(%{data: %{contact_type: :phone}}) do
{:ok, {:required, %{phone: {:required, :string}}}}
end
defp validate_contact(%{data: %{contact_type: :both}}) do
{:ok, {:required, %{
email: {:required, :string},
phone: {:required, :string}
}}}
end
endCustom Validation
Simple Custom Validator
defmodule ProductSchema do
import Peri
defschema :product, %{
price: {:custom, &validate_price/1}
}
defp validate_price(price) when price > 0, do: :ok
defp validate_price(price), do: {:error, "price must be positive, got %{price}", [price: price]}
endMFA Custom Validators
defmodule OrderSchema do
import Peri
defschema :order, %{
items: {:list, {:custom, {__MODULE__, :validate_item, [:in_stock]}}},
total: {:custom, {Calculator, :validate_total}}
}
def validate_item(item, :in_stock) do
if item.stock > 0 do
:ok
else
{:error, "item %{name} is out of stock", [name: item.name]}
end
end
endCallback Arities
1-Arity Callbacks (Root Data)
Receives the entire root data structure:
%{
user_type: :premium,
features: {:cond, fn data -> data.user_type == :premium end,
{:list, :string},
{:literal, []}}
}2-Arity Callbacks (Current + Root)
Receives current context and root data - useful for lists:
defmodule ItemSchema do
import Peri
defschema :item, %{
type: {:required, :string},
# Validates based on current item's type, not parent data
value: {:dependent, fn current, _root ->
case current.type do
"number" -> {:ok, :integer}
"text" -> {:ok, :string}
_ -> {:ok, :any}
end
end}
}
defschema :collection, %{
items: {:list, get_schema(:item)}
}
end
# Each item validates independently
data = %{
items: [
%{type: "number", value: 42},
%{type: "text", value: "hello"}
]
}Schema Composition
Reusable Schemas
defmodule BaseSchemas do
import Peri
defschema :address, %{
street: {:required, :string},
city: {:required, :string},
zip: {:string, {:regex, ~r/^\d{5}$/}}
}
defschema :person, %{
name: {:required, :string},
address: get_schema(:address)
}
defschema :company, %{
name: {:required, :string},
headquarters: get_schema(:address),
employees: {:list, get_schema(:person)}
}
endSchema Merging
defmodule UserSchemas do
import Peri
defschema :base_user, %{
name: {:required, :string},
email: {:required, :string}
}
defschema :admin_user, Map.merge(get_schema(:base_user), %{
permissions: {:required, {:list, :string}},
last_login: :datetime
})
endError Context
Custom validators can provide detailed error context:
defp validate_complex_rule(value) do
case expensive_validation(value) do
:ok -> :ok
{:error, reason} ->
{:error, "validation failed: %{reason} for value %{value}",
[reason: reason, value: inspect(value)]}
end
endDecode and Encode
Coercion is a schema directive, so decoding wire data is just validation:
Peri.decode/3 is an alias of Peri.validate/3 that reads naturally at the
boundary. Peri.encode/3 runs the same schema in the opposite direction — it
validates the data against the target types, then produces the wire
representation:
schema = %{
"page" => {:coerce, :string, :integer},
"since" => {:coerce, :string, :date}
}
{:ok, decoded} = Peri.decode(schema, %{"page" => "2", "since" => "2024-03-15"})
# => {:ok, %{"page" => 2, "since" => ~D[2024-03-15]}}
{:ok, wire} = Peri.encode(schema, decoded)
# => {:ok, %{"page" => "2", "since" => "2024-03-15"}}On encode, built-in :string sources render with to_string/1
(to_iso8601/1 for date/time structs). A coerce directive may carry an
encode: opt to customize the reverse, and custom function/MFA sources
without an encode: opt pass the validated value through unchanged.
Two related directives round out the codec story:
{type, {:encode, fun}}appliesfun(or an MFA) to the value duringPeri.encode/3only; it is inert undervalidate/3anddecode/3.{type, {:transform, fun}}is skipped duringPeri.encode/3— transforms are decode-only.
Note that Peri.encode/3 still validates: encoding data that does not match
the target types returns the usual {:error, errors} tuple.
Rendering Errors
Peri.Error.humanize/1 converts an error (or list of errors) into a
nested, data-shaped map of messages, mirroring
Ecto.Changeset.traverse_errors/2 output. Integer path segments become
integer keys (list indices), and multiple errors at the same path append
to the message list:
{:error, errors} = MySchemas.user(%{addresses: [%{street: "x"}]})
Peri.Error.humanize(errors)
# => %{addresses: %{0 => %{street: ["is too short"]}}}This shape drops straight into API error payloads and form rendering.
Because humanize/1 reads each leaf's final message verbatim, it composes
with traverse_errors/2: translate first, then humanize.
Errors without a path come from top-level scalar validations
(Peri.validate(:string, 5)); with no field to nest under, they humanize
to a bare list of messages.
Missing Key Suggestions
When a required field is missing but the data contains a key that looks
like a typo of it (Jaro distance above 0.8, compared across atom and
string key forms), the error message gains a suggestion and the error
content carries it under :did_you_mean:
{:error, [error]} = Peri.validate(%{email: {:required, :string}}, %{emial: "a@b.c"})
error.message
# => "is required, expected type of :string did you mean :emial?"
error.content[:did_you_mean]
# => :emialThis works in both strict and permissive modes, including inside nested
schemas and list elements. A static or MFA error: override still
replaces the message; the :did_you_mean content remains available.