Validation Patterns

Copy Markdown View Source

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}
  }
end

Dependent 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", []}
end

Multiple 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
end

Custom 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]}
end

MFA 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
end

Callback 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)}
  }
end

Schema 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
  })
end

Error 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
end

Decode 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}} applies fun (or an MFA) to the value during Peri.encode/3 only; it is inert under validate/3 and decode/3.
  • {type, {:transform, fun}} is skipped during Peri.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]
# => :emial

This 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.