Guide
Quick Start & Workflow
Follow this path to get ExJoi running in a fresh Elixir project. We cover installation, schema design, validation, and tooling so you can ship consistent rules across APIs, CLIs, and workers.
API Reference
Core Functions
Complete reference for all ExJoi functions with parameters, options, and examples.
ExJoi.schema/2
Creates a validation schema from a map of field rules.
Parameters
fields(map, required) - Map where keys are field names and values are rule definitionsopts(keyword list, optional) - Options for the schema
Options
:defaults(map) - Default values merged into input data before validation (top-level only)
Returns
%ExJoi.Schema{} - A schema struct ready for validation
# Basic schema
schema = ExJoi.schema(%{
name: ExJoi.string(required: true),
age: ExJoi.number()
})
# Schema with defaults
schema = ExJoi.schema(
%{
name: ExJoi.string(required: true),
active: ExJoi.boolean()
},
defaults: %{active: true}
)
ExJoi.validate/3
Validates data against a schema. Returns {:ok, validated_data} on success or {:error, errors} on failure.
Parameters
data(map, required) - The data to validateschema(%ExJoi.Schema{}, required) - The schema to validate againstopts(keyword list, optional) - Validation options
Options
:convert(boolean, default:false) - Enable type coercion (string to number, boolean, date, array)
Returns
{:ok, validated_data}- Success tuple with validated and normalized data{:error, error_map}- Error tuple with structured error information
# Strict validation
ExJoi.validate(%{name: "John", age: 30}, schema)
# => {:ok, %{name: "John", age: 30}}
# With convert mode
ExJoi.validate(%{"name" => "John", "age" => "30"}, schema, convert: true)
# => {:ok, %{"name" => "John", "age" => 30}}
# Validation failure
ExJoi.validate(%{age: 30}, schema)
# => {:error, %{message: "Validation failed", errors: %{name: [%{code: :required, message: "is required"}]}, errors_flat: %{"name" => ["is required"]}}}
ExJoi.string/1
Creates a string validator rule.
Options
:required(boolean, default:false) - Field must be present:min(integer) - Minimum string length:max(integer) - Maximum string length:pattern(Regex) - Regex pattern the string must match:email(boolean, default:false) - Validate email format
Returns
%ExJoi.Rule{type: :string, ...}
ExJoi.string()
ExJoi.string(required: true, min: 3, max: 50)
ExJoi.string(pattern: ~r/^[A-Z]+$/)
ExJoi.string(email: true)
ExJoi.number/1
Creates a number validator rule.
Options
:required(boolean, default:false) - Field must be present:min(number) - Minimum value (inclusive):max(number) - Maximum value (inclusive):integer(boolean, default:false) - Only accept integers
Returns
%ExJoi.Rule{type: :number, ...}
ExJoi.number()
ExJoi.number(required: true, min: 18, max: 65)
ExJoi.number(integer: true)
ExJoi.number(min: 0, max: 100)
ExJoi.boolean/1
Creates a boolean validator rule.
Options
:required(boolean, default:false) - Field must be present:truthy(list) - Custom list of values that coerce totrue:falsy(list) - Custom list of values that coerce tofalse
Default Truthy Values
[true, "true", "True", "TRUE", "1", 1, "yes", "Yes", "YES", "on", "On", "ON"]
Default Falsy Values
[false, "false", "False", "FALSE", "0", 0, "no", "No", "NO", "off", "Off", "OFF"]
Returns
%ExJoi.Rule{type: :boolean, ...}
ExJoi.boolean()
ExJoi.boolean(required: true)
ExJoi.boolean(truthy: ["Y"], falsy: ["N"])
ExJoi.boolean(truthy: ["enabled", "active"], falsy: ["disabled", "inactive"])
ExJoi.object/2
Creates a nested object validator. Accepts either a map of field rules or an existing %ExJoi.Schema{}.
Parameters
fields_or_schema(map |%ExJoi.Schema{}, required) - Either a map of field rules or a schemaopts(keyword list, optional) - Options for the object rule
Options
:required(boolean, default:false) - Object must be present
Returns
%ExJoi.Rule{type: :object, schema: %ExJoi.Schema{}, ...}
# From map
ExJoi.object(%{
email: ExJoi.string(required: true, email: true),
phone: ExJoi.string()
})
# From schema
nested_schema = ExJoi.schema(%{email: ExJoi.string(email: true)})
ExJoi.object(nested_schema, required: true)
ExJoi.array/1
Creates an array validator rule.
Options
:required(boolean, default:false) - Array must be present:of(%ExJoi.Rule{}) - Rule applied to each element:min_itemsor:min(integer) - Minimum array length:max_itemsor:max(integer) - Maximum array length:unique(boolean, default:false) - All elements must be unique:delimiter(string, default:",") - Delimiter for string-to-array coercion
Returns
%ExJoi.Rule{type: :array, ...}
ExJoi.array(of: ExJoi.string(min: 3), min_items: 1)
ExJoi.array(of: ExJoi.number(integer: true), unique: true)
ExJoi.array(delimiter: "|")
ExJoi.array(of: ExJoi.string(), min_items: 1, max_items: 10, unique: true)
ExJoi.date/1
Creates a date validator rule. Values are returned as DateTime structs when parsing succeeds.
Options
:required(boolean, default:false) - Date must be present
Returns
%ExJoi.Rule{type: :date, ...}
ExJoi.date()
ExJoi.date(required: true)
01 · Install
Bring ExJoi into your project
Add the dependency, fetch packages, and make sure the docs task is available for local previews.
defp deps do
[
{:exjoi, "~> 0.8.0"},
{:ex_doc, "~> 0.30", only: :dev, runtime: false}
]
end
$ mix deps.get
$ mix compile
$ mix docs # optional: preview documentation locally
02 · Build a schema
Describe data with the DSL
Start with a map of fields and attach typed rules. Everything is composable, so you can nest objects, arrays, conditionals, and customs as needed.
schema =
ExJoi.schema(%{
name: ExJoi.string(required: true, min: 2, max: 50),
age: ExJoi.number(required: true, min: 18),
role: ExJoi.string(required: true),
permissions:
ExJoi.when(
:role,
is: "admin",
then: ExJoi.array(of: ExJoi.string(min: 3), min_items: 1, required: true),
otherwise: ExJoi.array(of: ExJoi.string())
),
profile:
ExJoi.object(%{
email: ExJoi.string(required: true, email: true),
timezone: ExJoi.string()
})
})
Pro tip: keep schemas close to the boundary you’re validating (controllers, LiveView handlers, etc.) so you can inject context such as locale-based messages or convert mode per request.
03 · Validate data
Strict by default, convert when you need it
params = %{"name" => "Maya", "age" => 29, "role" => "editor"}
ExJoi.validate(params, schema)
# {:error, %{errors: %{profile: %{email: [%{message: "is required"}]}}}}
params = %{
"name" => "Maya",
"age" => "29",
"role" => "admin",
"permissions" => "read,write",
"profile" => %{"email" => "maya@example.com"}
}
ExJoi.validate(params, schema, convert: true)
- Convert mode affects numbers, booleans, ISO8601 dates, and arrays (using the delimiter you set).
- Conditional rules still run after conversion, so an admin with empty permissions fails even if other data passes.
04 · Common patterns
Real-world schema examples
See how ExJoi handles common validation scenarios in production applications.
defmodule MyApp.Schemas.UserRegistration do
def schema do
ExJoi.schema(%{
email: ExJoi.string(required: true, email: true, max: 255),
password: ExJoi.string(required: true, min: 8, max: 128),
password_confirmation: ExJoi.string(required: true),
name: ExJoi.string(required: true, min: 2, max: 100),
age: ExJoi.number(min: 13, max: 120, integer: true),
terms_accepted: ExJoi.boolean(required: true, truthy: ["true", "1", "yes"]),
newsletter: ExJoi.boolean(truthy: ["true", "1"]),
preferences: ExJoi.object(%{
theme: ExJoi.string(in: ["light", "dark", "auto"]),
language: ExJoi.string(min: 2, max: 5)
})
})
end
end
# Usage in controller
def create(conn, params) do
case ExJoi.validate(params, UserRegistration.schema(), convert: true) do
{:ok, normalized} ->
# Additional business logic: check password match
if normalized["password"] == normalized["password_confirmation"] do
Users.create(normalized)
else
{:error, %{errors_flat: %{"password_confirmation" => ["must match password"]}}}
end
{:error, %{errors_flat: errors}} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: errors})
end
end
defmodule MyApp.Schemas.Pagination do
def schema do
ExJoi.schema(%{
page: ExJoi.number(min: 1, integer: true),
per_page: ExJoi.number(min: 1, max: 100, integer: true),
sort: ExJoi.string(in: ["asc", "desc"]),
order_by: ExJoi.string(min: 1, max: 50)
}, defaults: %{page: 1, per_page: 20, sort: "desc"})
end
end
# Usage
params = %{"page" => "2", "per_page" => "50"}
{:ok, normalized} = ExJoi.validate(params, Pagination.schema(), convert: true)
# normalized = %{"page" => 2, "per_page" => 50, "sort" => "desc", "order_by" => nil}
schema = ExJoi.schema(%{
company: ExJoi.object(%{
name: ExJoi.string(required: true, min: 2, max: 100),
website: ExJoi.string(pattern: ~r/^https?:\/\/.+/),
address: ExJoi.object(%{
street: ExJoi.string(required: true),
city: ExJoi.string(required: true),
zip: ExJoi.string(required: true, pattern: ~r/^\d{5}(-\d{4})?$/),
country: ExJoi.string(required: true, min: 2, max: 2)
})
}),
employees: ExJoi.array(
of: ExJoi.object(%{
name: ExJoi.string(required: true, min: 2),
email: ExJoi.string(required: true, email: true),
role: ExJoi.string(required: true, in: ["developer", "designer", "manager"])
}),
min_items: 1,
unique: true
)
})
05 · Integration patterns
Where to use ExJoi in your app
ExJoi fits naturally into Phoenix controllers, LiveView handlers, and background jobs.
Phoenix Controllers
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} ->
conn
|> put_status(:created)
|> json(%{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 handle_event("save", %{"user" => params}, socket) do
case ExJoi.validate(params, UserSchema.schema(), convert: true) do
{:ok, normalized} ->
case Users.create(normalized) do
{:ok, _user} ->
{:noreply, redirect(socket, to: ~p"/users")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to create user")}
end
{:error, %{errors_flat: errors}} ->
{:noreply, assign(socket, :form_errors, errors)}
end
end
end
Command Pattern
defmodule MyApp.Commands.CreateOrder do
defstruct [:user_id, :items, :shipping_address]
def new(params) do
schema = ExJoi.schema(%{
user_id: ExJoi.string(required: true),
items: ExJoi.array(
of: ExJoi.object(%{
product_id: ExJoi.string(required: true),
quantity: ExJoi.number(required: true, min: 1, integer: true)
}),
min_items: 1
),
shipping_address: ExJoi.object(%{
street: ExJoi.string(required: true),
city: ExJoi.string(required: true),
zip: ExJoi.string(required: true)
})
})
case ExJoi.validate(params, schema, convert: true) do
{:ok, normalized} ->
{:ok, struct(__MODULE__, normalized)}
{:error, errors} ->
{:error, errors}
end
end
end
06 · Testing
Test your schemas thoroughly
Write comprehensive tests to ensure your validation rules work as expected.
defmodule MyApp.Schemas.UserSchemaTest do
use ExUnit.Case, async: true
alias MyApp.Schemas.UserSchema
describe "validate/2" do
test "accepts valid user data" do
params = %{
"name" => "John Doe",
"email" => "john@example.com",
"age" => 25
}
assert {:ok, normalized} = ExJoi.validate(params, UserSchema.schema())
assert normalized["name"] == "John Doe"
assert normalized["email"] == "john@example.com"
end
test "rejects invalid email" do
params = %{
"name" => "John Doe",
"email" => "not-an-email",
"age" => 25
}
assert {:error, %{errors_flat: errors}} = ExJoi.validate(params, UserSchema.schema())
assert Map.has_key?(errors, "email")
end
test "converts string numbers with convert: true" do
params = %{
"name" => "John",
"age" => "25"
}
assert {:ok, normalized} = ExJoi.validate(params, UserSchema.schema(), convert: true)
assert normalized["age"] == 25
assert is_integer(normalized["age"])
end
test "requires all mandatory fields" do
params = %{"name" => "John"}
assert {:error, %{errors_flat: errors}} = ExJoi.validate(params, UserSchema.schema())
assert Map.has_key?(errors, "email")
end
end
end
07 · Suggested workflow
Ship validations confidently
| Step | Description | Tools |
|---|---|---|
| Model | Sketch schemas alongside API contracts or LiveView assigns. | Figma, ADRs, ExJoi schema tests |
| Validate | Call ExJoi.validate/3 at the boundary, returning normalized data or errors. |
Pipelines, plug middleware, command handlers |
| Translate | Use message_translator for localized copy, and your own error_builder for API-shaped payloads. |
Gettext, central error modules |
| Automate | Run mix test/mix docs in CI so schema changes surface immediately. |
GitHub Actions, Buildkite, Fly CI |
08 · Best practices
Tips for maintainable schemas
1. Keep schemas close to boundaries
Define schemas in the same module or nearby where they're used (controllers, LiveView handlers). This makes it easier to understand context and requirements.
2. Use defaults for optional fields
Instead of making everything optional, use defaults in ExJoi.schema/2 to provide sensible defaults. This reduces nil checks downstream.
3. Extract complex schemas to modules
For reusable or complex schemas, create dedicated schema modules. This improves testability and reusability.
4. Test edge cases
Write tests for boundary conditions: empty strings, nil values, very long strings, negative numbers, etc. ExJoi handles these, but your business logic might not.
5. Use convert mode judiciously
Convert mode is powerful but can hide type issues. Use it for external APIs, CSV imports, or form submissions, but keep strict mode for internal APIs.
6. Combine with Ecto changesets
Use ExJoi for input validation at boundaries, then use Ecto changesets for database constraints. They complement each other perfectly.