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.

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 definitions
  • opts (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

Examples
# 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 validate
  • schema (%ExJoi.Schema{}, required) - The schema to validate against
  • opts (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
Examples
# 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, ...}

Examples
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, ...}

Examples
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 to true
  • :falsy (list) - Custom list of values that coerce to false

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, ...}

Examples
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 schema
  • opts (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{}, ...}

Examples
# 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_items or :min (integer) - Minimum array length
  • :max_items or :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, ...}

Examples
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, ...}

Examples
ExJoi.date()
ExJoi.date(required: true)

Bring ExJoi into your project

Add the dependency, fetch packages, and make sure the docs task is available for local previews.

mix.exs
defp deps do
  [
    {:exjoi, "~> 0.8.0"},
    {:ex_doc, "~> 0.30", only: :dev, runtime: false}
  ]
end
CLI
$ mix deps.get
$ mix compile
$ mix docs   # optional: preview documentation locally

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

Strict by default, convert when you need it

Strict mode
params = %{"name" => "Maya", "age" => 29, "role" => "editor"}
ExJoi.validate(params, schema)
# {:error, %{errors: %{profile: %{email: [%{message: "is required"}]}}}}
Convert mode
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.

Real-world schema examples

See how ExJoi handles common validation scenarios in production applications.

User registration
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
API pagination
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}
Nested data structures
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
  )
})

Where to use ExJoi in your app

ExJoi fits naturally into Phoenix controllers, LiveView handlers, and background jobs.

Phoenix Controllers

controller.ex
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

live_view.ex
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

command.ex
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

Test your schemas thoroughly

Write comprehensive tests to ensure your validation rules work as expected.

schema_test.exs
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

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

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.