v0.9 · Async validation + parallel processing

ExJoi brings expressive, real-time validation to Elixir.

Schema-first DSL. Convert mode. Conditional rules. Custom validators. Async validation with parallel processing. 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.

Async validation

Validate with external services, database lookups, and parallel processing using Task.async_stream.

Each topic gets its own page

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

Install & Validate

mix.exs
defp deps do
  [
    {:exjoi, "~> 0.9.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()
  )

Parallel validation with Task.async_stream

Validate with external services, database lookups, and long-running computations. Multiple async fields run in parallel for optimal performance.

External service check
username: ExJoi.async(
  ExJoi.string(required: true, min: 3),
  fn value, _ctx ->
    Task.async(fn ->
      if UsernameService.available?(value) do
        {:ok, value}
      else
        {:error, [%{code: :username_taken, message: "username is already taken"}]}
      end
    end)
  end,
  timeout: 3000
)

Parallel processing

Multiple async fields validate simultaneously using Task.async_stream.

# All three run in parallel
schema = ExJoi.schema(%{
  username: ExJoi.async(rule1, fn1),
  email: ExJoi.async(rule2, fn2),
  api_key: ExJoi.async(rule3, fn3)
})

ExJoi.validate(data, schema, max_concurrency: 5)

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
9 (current) Shipped Async validation with Task.async_stream, external service checks, timeout control, parallel array validation
8 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 Macro DSL, compiler, performance improvements