v0.8 · Error tree + translations

ExJoi brings expressive, real-time validation to Elixir.

Schema-first DSL. Convert mode. Conditional rules. Custom validators. All packaged with an extendable runtime so your product teams can ship cohesive validations everywhere.

Why teams adopt ExJoi

Schema-first DSL

Declarative helpers for strings, numbers, booleans, objects, arrays, dates, and conditionals.

Convert & extend

Toggle convert mode per validation and register custom validators via ExJoi.extend/2.

Error tree & translations

Consume flattened, path-based errors and global translators for localized messaging.

Each topic gets its own page

Prefer multi-page docs? Jump into these standalone guides.

Install & Validate

mix.exs
defp deps do
  [
    {:exjoi, "~> 0.8.0"}
  ]
end
Schema
schema =
  ExJoi.schema(%{
    role: ExJoi.string(required: true),
    friends: ExJoi.array(of: ExJoi.string(min: 3), min_items: 1, unique: true),
    permissions:
      ExJoi.when(
        :role,
        is: "admin",
        then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true),
        otherwise: ExJoi.array(of: ExJoi.string())
      )
  })

Path-based errors & translations

Use the new errors_flat map for dotted paths (e.g., user.email) and plug a translator for localized copy.

Flattened output
{:error,
 %{
   errors_flat: %{
     "user.email" => ["must be a valid email"],
     "permissions.0" => ["must be at least 3 characters"]
   }
 }}
Translator hook
ExJoi.configure(
  message_translator: fn
    :required, _default, _meta -> "es requerido"
    _code, default, _meta -> default
  end
)

Automatic casting when you want it

Pass convert: true to coerce strings into numbers, booleans, arrays (via delimiters), and ISO dates. Default strict mode keeps types honest.

Convert Mode
params = %{"age" => "42", "active" => "true", "onboarded_at" => "2025-01-01T00:00:00Z"}
ExJoi.validate(params, schema, convert: true)
# {:ok, %{"age" => 42, "active" => true, "onboarded_at" => ~U[2025-01-01 00:00:00Z]}}

Dynamic validation via ExJoi.when/3

Field-driven

Switch validators based on another field’s value, range, or regex pattern.

guardian_contact:
  ExJoi.when(
    :age,
    max: 17,
    then: ExJoi.string(required: true, min: 5)
  )

Regex-driven

Use `:matches` for plan tiers, locales, or SKU prefixes.

pro_feature_flag:
  ExJoi.when(
    :plan,
    matches: ~r/^pro/i,
    then: ExJoi.boolean(required: true),
    otherwise: ExJoi.boolean()
  )

Extend ExJoi for your domain

Function-based validator
ExJoi.extend(:uuid, fn value ->
  if Regex.match?(~r/^[0-9a-f-]{32}$/i, value) do
    {:ok, String.downcase(value)}
  else
    {:error, [%{code: :uuid, message: "must be a UUID"}]}
  end
end)

schema = ExJoi.schema(%{id: ExJoi.custom(:uuid, required: true)})

Module-based validator

Implement ExJoi.CustomValidator to reuse logic.

defmodule MyApp.UUIDValidator do
  @behaviour ExJoi.CustomValidator

  def validate(value, _rule, _ctx) do
    case Ecto.UUID.cast(value) do
      {:ok, uuid} -> {:ok, uuid}
      :error -> {:error, [%{code: :uuid, message: "invalid UUID"}]}
    end
  end
end

Try schema logic in real time

Toggle role and permissions to preview which branch of ExJoi.when/3 activates. This is a conceptual helper—hook it into your own validation pipeline.

Result

// Change inputs and click “Simulate Validation”

Version milestones

Version Status Highlights
8 (current) Shipped Path-based error tree, translator hooks, enhanced builder
7 Shipped Custom validators, plugin registry, error builder overrides
6 Shipped Conditional rules with ExJoi.when/3
5 Shipped Convert mode (numbers, booleans, dates), ExJoi.date/1
4 Shipped Array validation (length, uniqueness, delimiter coercion)
Next In design Async / parallel validation, macro DSL, performance improvements