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.
API Reference
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})
{: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.
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
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
)
Structure
Nested map + flattened map
{: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"]
}
}}
Translators
Customize copy globally
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.
Custom builder
Shape the envelope for your API
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.
Error structure deep dive
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.
{: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.
{: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"]
}
}}
Using errors in your app
Integration patterns
See how to use ExJoi errors in Phoenix controllers, LiveView, and API responses.
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
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"""
"""
end
end
// 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(', ')}
))}
Message translation
Localize error messages
Use translators to provide localized error messages for international applications.
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
)
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
)
Error codes reference
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 | - |
Best practices
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.