Guide
Custom Validators & Plugin System
When the built-in helpers aren't enough, register your own domain-specific validators with ExJoi.extend/2.
Validators can be lightweight anonymous functions or fully fledged modules implementing ExJoi.CustomValidator.
API Reference
Custom Validator Functions
Complete reference for extending ExJoi with custom validators.
ExJoi.extend/2
Registers a custom validator under the provided type name. The validator can be either a function or a module implementing ExJoi.CustomValidator.
Parameters
type_name(atom, required) - The name to register the validator under (e.g.,:uuid,:slug)validator(function | module, required) - Either an anonymous function or a module implementingExJoi.CustomValidator
Function Validator Signature
When using a function, it receives:
value(any) - The value to validatecontext(map) - Context map containing:convert,:data,:custom_opts
Must return:
{:ok, validated_value}- Success tuple with validated/normalized value{:error, [error_map, ...]}- Error tuple with list of error maps
Module Validator
Modules must implement ExJoi.CustomValidator behaviour with a validate/3 function matching the same signature as function validators.
Returns
:ok - Always returns :ok on successful registration
# Function-based validator
ExJoi.extend(:slug, fn value, _ctx ->
if is_binary(value) and String.match?(value, ~r/^[a-z0-9-]+$/) do
{:ok, String.downcase(value)}
else
{:error, [%{code: :slug, message: "must be a valid slug"}]}
end
end)
# Module-based validator
defmodule MyApp.Validators.UUID do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, _ctx) do
case Ecto.UUID.cast(value) do
{:ok, uuid} -> {:ok, uuid}
:error -> {:error, [%{code: :uuid, message: "must be a UUID"}]}
end
end
end
ExJoi.extend(:uuid, MyApp.Validators.UUID)
ExJoi.custom/2
Creates a custom validator rule for a previously registered type. Use this in your schemas to apply custom validation.
Parameters
type_name(atom, required) - The name of a previously registered custom validator (must match the name used inExJoi.extend/2)opts(keyword list, optional) - Options for the custom validator rule
Options
:required(boolean, default:false) - Field must be present:custom_opts(keyword list) - Custom options passed to the validator function in the context map
Returns
%ExJoi.Rule{type: {:custom, type_name}, custom_opts: [...], ...}
Context Map
The validator function receives a context map with:
:convert(boolean) - Whether convert mode is enabled:data(map) - The full input data map:custom_opts(keyword list) - Options passed via:custom_optsin the rule
# Register the validator first
ExJoi.extend(:slug, fn value, _ctx ->
# validation logic
end)
# Use in schema
schema = ExJoi.schema(%{
handle: ExJoi.custom(:slug, required: true)
})
# With custom options
ExJoi.extend(:strong_password, fn value, %{custom_opts: opts} ->
min_length = Keyword.get(opts, :min_length, 8)
# validation logic using min_length
end)
schema = ExJoi.schema(%{
password: ExJoi.custom(:strong_password, required: true, custom_opts: [min_length: 12])
})
ExJoi.configure/1
Configures runtime options for ExJoi, including error builders and message translators.
Parameters
opts(keyword list, required) - Configuration options
Options
:error_builder(function, optional) - Function that receives the error map and returns any structure:message_translator(function, optional) - Function that translates error codes to localized messages
Error Builder Signature
Function receives:
errors(map) - Nested error map structure
Returns:
- Any structure you want (map, list, etc.)
Message Translator Signature
Function receives:
code(atom) - Error code (e.g.,:required,:string_email)default(string) - Default error messagemeta(map) - Metadata (e.g.,%{min: 3},%{max: 100})
Returns:
- (string) - Translated error message
Returns
:ok - Always returns :ok
# Configure error builder
ExJoi.configure(
error_builder: fn errors ->
%{
status: "invalid_params",
errors: errors,
errors_flat: ExJoi.Validator.flatten_errors(errors)
}
end
)
# Configure message translator
ExJoi.configure(
message_translator: fn code, default, meta ->
case code do
:required -> "es requerido"
:string_email -> "debe ser un email válido"
:string_min -> "debe tener al menos #{meta[:min]} caracteres"
_ -> default
end
end
)
# Configure both
ExJoi.configure(
error_builder: fn errors -> %{errors: errors} end,
message_translator: fn code, default, _meta -> default end
)
ExJoi.CustomValidator Behaviour
The behaviour that module-based validators must implement.
Required Callback
@callback validate(
value :: any(),
rule :: ExJoi.Rule.t(),
context :: %{
convert: boolean(),
data: map(),
custom_opts: keyword()
}
) :: {:ok, any()} | {:error, list(ExJoi.Validator.error())}
Parameters
value(any) - The value to validate (may be coerced if convert mode is enabled)rule(%ExJoi.Rule{}) - The rule struct (containscustom_opts)context(map) - Context map with:convert,:data,:custom_opts
Returns
{:ok, validated_value}- Success tuple with validated/normalized value{:error, [error_map, ...]}- Error tuple with list of error maps
Error Map Structure
Each error map should have:
:code(atom) - Error code (e.g.,:uuid,:slug):message(string) - Human-readable error message:meta(map, optional) - Additional metadata
defmodule MyApp.Validators.UUID do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, %{convert: convert?}) do
case Ecto.UUID.cast(value) do
{:ok, uuid} -> {:ok, uuid}
:error when convert? ->
{:error, [%{code: :uuid, message: "must be a UUID string"}]}
:error ->
{:error, [%{code: :uuid, message: "must be a UUID"}]}
end
end
end
Function-based
Inline validators for quick wins
ExJoi.extend(:slug, fn value, _ctx ->
with true <- is_binary(value),
true <- String.length(value) >= 3,
true <- String.match?(value, ~r/^[a-z0-9-]+$/) do
{:ok, String.downcase(value)}
else
_ -> {:error, [%{code: :slug, message: "must contain only lowercase, numbers, or dashes"}]}
end
end)
schema = ExJoi.schema(%{handle: ExJoi.custom(:slug, required: true)})
Module-based
Reusable validators for teams
defmodule MyApp.Validators.UUID do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, %{convert: convert?}) do
case Ecto.UUID.cast(value) do
{:ok, uuid} -> {:ok, uuid}
:error when convert? -> {:error, [%{code: :uuid, message: "must be a UUID string"}]}
:error -> {:error, [%{code: :uuid, message: "must be a UUID"}]}
end
end
end
ExJoi.extend(:uuid, MyApp.Validators.UUID)
Modules support dependency injection, logging, and other niceties. They receive the full context map, including :convert, the raw data payload, and any custom options you pass into ExJoi.custom/2.
Error builder
Shape responses for your API
ExJoi.configure(
error_builder: fn errors ->
%{
status: "invalid_params",
errors: errors,
errors_flat: ExJoi.Validator.flatten_errors(errors)
}
end
)
Pair custom validators with a builder so UI teams get the exact structure they expect. Combine this with translators (see the Error Tree guide) for localized copy.
More examples
Common custom validators
See how to build validators for common use cases.
ExJoi.extend(:url, fn value, _ctx ->
if is_binary(value) and String.match?(value, ~r/^https?:\/\/.+/i) do
{:ok, value}
else
{:error, [%{code: :url, message: "must be a valid URL"}]}
end
end)
schema = ExJoi.schema(%{
website: ExJoi.custom(:url, required: true)
})
ExJoi.extend(:phone, fn value, _ctx ->
# Remove common formatting characters
cleaned = value
|> String.replace(~r/[\s\-\(\)]/, "")
if String.match?(cleaned, ~r/^\d{10,15}$/) do
{:ok, cleaned}
else
{:error, [%{code: :phone, message: "must be a valid phone number"}]}
end
end)
schema = ExJoi.schema(%{
phone: ExJoi.custom(:phone, required: true)
})
# Accepts: "(555) 123-4567", "555-123-4567", "5551234567"
# Normalizes to: "5551234567"
defmodule MyApp.Validators.CreditCard do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, _ctx) do
cleaned = String.replace(value, ~r/\s/, "")
cond do
!String.match?(cleaned, ~r/^\d{13,19}$/) ->
{:error, [%{code: :credit_card, message: "must be 13-19 digits"}]}
!luhn_valid?(cleaned) ->
{:error, [%{code: :credit_card, message: "invalid card number"}]}
true ->
{:ok, cleaned}
end
end
defp luhn_valid?(number) do
number
|> String.reverse()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {digit, idx} ->
d = String.to_integer(digit)
if rem(idx, 2) == 1, do: d * 2, else: d
end)
|> Enum.map(fn n -> if n > 9, do: n - 9, else: n end)
|> Enum.sum()
|> rem(10) == 0
end
end
ExJoi.extend(:credit_card, MyApp.Validators.CreditCard)
ExJoi.extend(:strong_password, fn value, %{custom_opts: opts} ->
min_length = Keyword.get(opts || [], :min_length, 8)
require_upper = Keyword.get(opts || [], :require_upper, true)
require_lower = Keyword.get(opts || [], :require_lower, true)
require_number = Keyword.get(opts || [], :require_number, true)
require_special = Keyword.get(opts || [], :require_special, false)
errors = []
errors = if String.length(value) < min_length do
["must be at least #{min_length} characters" | errors]
else
errors
end
errors = if require_upper and !String.match?(value, ~r/[A-Z]/) do
["must contain an uppercase letter" | errors]
else
errors
end
errors = if require_lower and !String.match?(value, ~r/[a-z]/) do
["must contain a lowercase letter" | errors]
else
errors
end
errors = if require_number and !String.match?(value, ~r/\d/) do
["must contain a number" | errors]
else
errors
end
errors = if require_special and !String.match?(value, ~r/[!@#$%^&*(),.?":{}|<>]/) do
["must contain a special character" | errors]
else
errors
end
if Enum.empty?(errors) do
{:ok, value}
else
{:error, Enum.map(errors, fn msg -> %{code: :strong_password, message: msg} end)}
end
end)
schema = ExJoi.schema(%{
password: ExJoi.custom(:strong_password, required: true, custom_opts: [
min_length: 12,
require_special: true
])
})
Module-based validators
Advanced patterns
Modules give you more power: dependency injection, logging, and complex validation logic.
defmodule MyApp.Validators.UniqueEmail do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, _ctx) do
case MyApp.Repo.get_by(MyApp.User, email: value) do
nil -> {:ok, value}
_user -> {:error, [%{code: :unique_email, message: "email already taken"}]}
end
end
end
ExJoi.extend(:unique_email, MyApp.Validators.UniqueEmail)
schema = ExJoi.schema(%{
email: ExJoi.string(required: true, email: true)
|> ExJoi.custom(:unique_email)
})
defmodule MyApp.Validators.AllowedDomain do
@behaviour ExJoi.CustomValidator
@impl true
def validate(value, _rule, %{custom_opts: opts, data: data}) do
allowed_domains = Keyword.get(opts || [], :domains, [])
current_user_domain = Map.get(data, "current_user_domain")
email_domain = value
|> String.split("@")
|> List.last()
cond do
email_domain in allowed_domains ->
{:ok, value}
email_domain == current_user_domain ->
{:ok, value}
true ->
{:error, [%{code: :allowed_domain, message: "email domain not allowed"}]}
end
end
end
ExJoi.extend(:allowed_domain, MyApp.Validators.AllowedDomain)
schema = ExJoi.schema(%{
email: ExJoi.string(required: true, email: true)
|> ExJoi.custom(:allowed_domain, custom_opts: [domains: ["company.com", "partner.com"]])
})
Error builder customization
Shape error responses
Customize how validation errors are formatted for your API consumers.
ExJoi.configure(
error_builder: fn errors ->
source_pointer = fn path ->
"/data/attributes/#{String.replace(path, ".", "/")}"
end
jsonapi_errors = errors
|> ExJoi.Validator.flatten_errors()
|> Enum.map(fn {path, messages} ->
%{
status: "422",
source: %{pointer: source_pointer.(path)},
title: "Validation Error",
detail: Enum.join(messages, ", ")
}
end)
%{
errors: jsonapi_errors
}
end
)
ExJoi.configure(
error_builder: fn errors ->
%{
success: false,
validation_errors: ExJoi.Validator.flatten_errors(errors)
}
end
)
Best practices
Writing effective custom validators
1. Return clear error messages
Error messages should tell users exactly what's wrong and how to fix it.
2. Use appropriate error codes
Error codes help with translation and programmatic handling. Use descriptive codes like :unique_email instead of generic ones.
3. Handle edge cases
What happens with nil, empty strings, or unexpected types? Always validate input types first.
4. Keep validators focused
Each validator should do one thing well. Combine multiple validators in a schema rather than creating a monolithic validator.
5. Test thoroughly
Write tests for valid inputs, invalid inputs, edge cases, and error message formatting.