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.

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 implementing ExJoi.CustomValidator

Function Validator Signature

When using a function, it receives:

  • value (any) - The value to validate
  • context (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

Examples
# 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 in ExJoi.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_opts in the rule
Examples
# 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 message
  • meta (map) - Metadata (e.g., %{min: 3}, %{max: 100})

Returns:

  • (string) - Translated error message

Returns

:ok - Always returns :ok

Examples
# 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 (contains custom_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
Example Implementation
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

Inline validators for quick wins

extend/2
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)})

Reusable validators for teams

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

Shape responses for your API

configure/1
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.

Common custom validators

See how to build validators for common use cases.

URL validator
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)
})
Phone number validator
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"
Credit card validator
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)
Password strength validator
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
  ])
})

Advanced patterns

Modules give you more power: dependency injection, logging, and complex validation logic.

Database-backed validator
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)
})
Validator with context
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"]])
})

Shape error responses

Customize how validation errors are formatted for your API consumers.

JSON:API format
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
)
Simple flat format
ExJoi.configure(
  error_builder: fn errors ->
    %{
      success: false,
      validation_errors: ExJoi.Validator.flatten_errors(errors)
    }
  end
)

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.