Guide

Full Error Tree & Translators

Version 8 introduces a flattened error tree (errors_flat) plus translator hooks so you can ship localized, path-aware error messages that mirror Joi's format.

Error Handling Functions

Complete reference for error structure and configuration.

Error Structure

When validation fails, ExJoi.validate/3 returns an error map with the following structure:

Error Map Fields

  • :message (string) - Default: "Validation failed"
  • :errors (map) - Nested error map with field paths as keys
  • :errors_flat (map) - Flattened error map with dotted paths as keys (e.g., "user.email")

Individual Error Structure

Each error in the :errors map is a list of error maps, each containing:

  • :code (atom) - Error code (e.g., :required, :string_email, :number_min)
  • :message (string) - Human-readable error message (may be translated)
  • :meta (map, optional) - Additional metadata (e.g., %{min: 3}, %{max: 100})
Example Error Structure
{:error,
 %{
   message: "Validation failed",
   errors: %{
     user: %{
       email: [
         %{code: :string_email, message: "must be a valid email", meta: %{}}
       ]
     },
     age: [
       %{code: :number_min, message: "must be at least 18", meta: %{min: 18}}
     ]
   },
   errors_flat: %{
     "user.email" => ["must be a valid email"],
     "age" => ["must be at least 18"]
   }
 }}

ExJoi.configure/1 - Error Builder

Override the default error structure by providing a custom error builder function.

Error Builder Function

Function signature:

fn errors :: map() -> any()

Receives the nested error map and returns any structure you want.

Example
ExJoi.configure(
  error_builder: fn errors ->
    %{
      status: "invalid_params",
      errors: errors,
      errors_flat: ExJoi.Validator.flatten_errors(errors)
    }
  end
)

ExJoi.configure/1 - Message Translator

Translate error messages based on error codes and metadata.

Message Translator Function

Function signature:

fn code :: atom(), default :: String.t(), meta :: map() -> String.t()

Receives error code, default message, and metadata. Returns the translated message.

Common Error Codes

  • :required - Field is required
  • :string_min - String too short
  • :string_max - String too long
  • :string_email - Invalid email format
  • :number_min - Number too small
  • :number_max - Number too large
  • :number_integer - Must be an integer
  • :array_min_items - Array too short
  • :array_max_items - Array too long
  • :array_unique - Array contains duplicates
Example
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"
      :number_min -> "debe ser al menos #{meta[:min]}"
      _ -> default
    end
  end
)

Nested map + flattened map

Payload
{:error,
 %{
   message: "Validation failed",
   errors: %{
     user: %{
       email: [
         %{code: :string_email, message: "must be a valid email"}
       ]
     },
     permissions: %{
       0 => [%{code: :string_min, message: "must be at least 3 characters"}]
     }
   },
   errors_flat: %{
     "user.email" => ["must be a valid email"],
     "permissions.0" => ["must be at least 3 characters"]
   }
 }}

Customize copy globally

configure/1
ExJoi.configure(
  message_translator: fn
    :string_email, _default, _meta -> dgettext("errors", "please provide a valid email")
    :required, _default, %{field: field} -> "#{field} es requerido"
    _code, default, _meta -> default
  end
)

Translators receive the error code, the default message, and any metadata (like %{min: 3}). Combine this with Gettext or your own dictionaries.

Shape the envelope for your API

error_builder
ExJoi.configure(
  error_builder: fn errors ->
    %{
      status: 422,
      reason: "invalid_parameters",
      errors: errors,
      errors_flat: ExJoi.Validator.flatten_errors(errors)
    }
  end
)

Builders run at the end of validation, after translators have executed. That means your HTTP layer always sees the exact structure defined here, regardless of how schemas evolve.

Understanding the error format

ExJoi provides both nested and flattened error structures for different use cases.

Nested Errors (errors)

The nested structure mirrors your data structure, making it easy to map errors back to form fields.

Example
{:error,
 %{
   message: "Validation failed",
   errors: %{
     user: %{
       email: [
         %{code: :string_email, message: "must be a valid email"},
         %{code: :string_max, message: "must be at most 255 characters", meta: %{max: 255}}
       ],
       profile: %{
         age: [%{code: :number_min, message: "must be ≥ 18", meta: %{min: 18}}]
       }
     },
     permissions: %{
       0 => [%{code: :string_min, message: "must be at least 3 characters", meta: %{min: 3}}],
       2 => [%{code: :string_min, message: "must be at least 3 characters", meta: %{min: 3}}]
     }
   }
 }}

Flattened Errors (errors_flat)

The flattened structure uses dotted paths, perfect for frontend frameworks and API responses.

Example
{:error,
 %{
   message: "Validation failed",
   errors_flat: %{
     "user.email" => [
       "must be a valid email",
       "must be at most 255 characters"
     ],
     "user.profile.age" => ["must be ≥ 18"],
     "permissions.0" => ["must be at least 3 characters"],
     "permissions.2" => ["must be at least 3 characters"]
   }
 }}

Integration patterns

See how to use ExJoi errors in Phoenix controllers, LiveView, and API responses.

Phoenix Controller
defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller

  def create(conn, params) do
    case ExJoi.validate(params, UserSchema.schema(), convert: true) do
      {:ok, normalized} ->
        case Users.create(normalized) do
          {:ok, user} ->
            json(conn, %{data: user})
          
          {:error, changeset} ->
            conn
            |> put_status(:unprocessable_entity)
            |> json(%{errors: format_changeset_errors(changeset)})
        end
      
      {:error, %{errors_flat: errors}} ->
        conn
        |> put_status(:unprocessable_entity)
        |> json(%{errors: errors})
    end
  end
end
Phoenix LiveView
defmodule MyAppWeb.UserLive.Form do
  use MyAppWeb, :live_view

  def handle_event("validate", %{"user" => params}, socket) do
    case ExJoi.validate(params, UserSchema.schema(), convert: true) do
      {:ok, _normalized} ->
        {:noreply, assign(socket, :form_errors, %{})}
      
      {:error, %{errors_flat: errors}} ->
        {:noreply, assign(socket, :form_errors, errors)}
    end
  end

  def render(assigns) do
    ~H"""
    
<%= if @form_errors["user.email"] do %>
<%= Enum.join(@form_errors["user.email"], ", ") %>
<% end %>
""" end end
React/JavaScript
// API response
const response = await fetch('/api/users', {
  method: 'POST',
  body: JSON.stringify(userData)
});

const result = await response.json();

if (result.errors) {
  // errors_flat format
  Object.entries(result.errors).forEach(([field, messages]) => {
    const fieldElement = document.querySelector(`[name="${field}"]`);
    if (fieldElement) {
      fieldElement.classList.add('error');
      const errorDiv = document.createElement('div');
      errorDiv.className = 'error-message';
      errorDiv.textContent = messages.join(', ');
      fieldElement.parentNode.appendChild(errorDiv);
    }
  });
}

// Or with React
{Object.entries(errors).map(([field, messages]) => (
  
{field}: {messages.join(', ')}
))}

Localize error messages

Use translators to provide localized error messages for international applications.

Gettext integration
ExJoi.configure(
  message_translator: fn code, default, meta ->
    case code do
      :required ->
        Gettext.dgettext(MyApp.Gettext, "errors", "is required")
      
      :string_email ->
        Gettext.dgettext(MyApp.Gettext, "errors", "must be a valid email")
      
      :string_min ->
        Gettext.dngettext(
          MyApp.Gettext,
          "errors",
          "must be at least %{min} character",
          "must be at least %{min} characters",
          meta[:min] || 1,
          min: meta[:min] || 1
        )
      
      :number_min ->
        Gettext.dgettext(
          MyApp.Gettext,
          "errors",
          "must be at least %{min}",
          min: meta[:min]
        )
      
      _ ->
        default
    end
  end
)
Simple dictionary
defmodule MyApp.ErrorTranslator do
  @translations %{
    en: %{
      required: "is required",
      string_email: "must be a valid email",
      string_min: "must be at least %{min} characters",
      number_min: "must be at least %{min}"
    },
    es: %{
      required: "es requerido",
      string_email: "debe ser un email válido",
      string_min: "debe tener al menos %{min} caracteres",
      number_min: "debe ser al menos %{min}"
    },
    fr: %{
      required: "est requis",
      string_email: "doit être un email valide",
      string_min: "doit contenir au moins %{min} caractères",
      number_min: "doit être au moins %{min}"
    }
  }

  def translate(code, default, meta, locale \\ :en) do
    case Map.get(@translations[locale] || %{}, code) do
      nil -> default
      template -> String.replace(template, ~r/%\{(\w+)\}/, fn _, key ->
        to_string(Map.get(meta || %{}, String.to_atom(key), ""))
      end)
    end
  end
end

ExJoi.configure(
  message_translator: fn code, default, meta ->
    locale = get_locale() # Your function to get current locale
    MyApp.ErrorTranslator.translate(code, default, meta, locale)
  end
)

All available error codes

Understanding error codes helps you write better translators and handle errors programmatically.

Code When it occurs Meta fields
:required Required field is missing or nil %{field: "field_name"}
:string Value is not a string -
:string_min String is shorter than minimum %{min: 3}
:string_max String is longer than maximum %{max: 100}
:string_email String doesn't match email format -
:number Value is not a number -
:number_min Number is less than minimum %{min: 18}
:number_max Number is greater than maximum %{max: 120}
:array Value is not an array -
:array_min_items Array has fewer items than minimum %{min_items: 1}
:date Value is not a valid date -

Error handling guidelines

1. Use errors_flat for APIs

The flattened format is easier for frontend frameworks to consume. Use errors_flat in your API responses.

2. Use nested errors for forms

The nested structure mirrors your form structure, making it easy to map errors to specific form fields.

3. Translate at the boundary

Set up your translator once in your application startup or per-request middleware, not in every schema.

4. Customize error builder per API

Different APIs might need different error formats. Use custom error builders to match your API contract.

5. Include metadata in errors

Error codes and metadata help with programmatic error handling and debugging. Don't strip them out.