Guide
Convert Mode & Casting Strategies
ExJoi runs in strict mode by default. Activate convert: true per validation to coerce inbound strings, booleans, arrays, and ISO dates, mirroring Joi's "convert" behavior.
This guide explains what actually changes, how to stay safe, and how to mix convert mode with defaults and custom validators.
API Reference
ExJoi.validate/3 - Convert Mode
Complete reference for type coercion and conversion behavior.
Function Signature
ExJoi.validate(data, schema, opts \\ [])
Parameters
data (map, required)
The data to validate. When convert: true is enabled, string values will be coerced to appropriate types.
schema (%ExJoi.Schema{}, required)
The validation schema. Convert mode applies to all fields in the schema.
opts (keyword list, optional)
Validation options. The key option for convert mode is :convert.
Options
:convert (boolean, default: false)
When true, enables automatic type coercion for the following conversions:
String → Number
Numeric strings are parsed to integers or floats (e.g., "42" → 42, "3.14" → 3.14).
String → Boolean
Boolean-like strings are coerced using truthy/falsy lists (e.g., "true" → true, "false" → false).
String → Date
ISO8601 date strings are parsed to DateTime structs (e.g., "2024-01-15T10:30:00Z" → %DateTime{...}).
String → Array
Delimited strings are split into lists using the rule's :delimiter option (default: ",").
String Normalization
Strings are trimmed and whitespace is normalized (multiple spaces collapsed to single spaces).
Returns
Returns the same structure as normal validation:
{:ok, coerced_data}- Success tuple with validated and coerced data{:error, error_map}- Error tuple with structured error information
The difference is that coerced_data contains converted types (numbers, booleans, dates, arrays) instead of the original string values.
Examples
schema = ExJoi.schema(%{
age: ExJoi.number(required: true),
active: ExJoi.boolean(),
tags: ExJoi.array(of: ExJoi.string())
})
# Without convert mode (strict)
ExJoi.validate(%{"age" => "30", "active" => "true"}, schema)
# => {:error, ...} # Type mismatch errors
# With convert mode
ExJoi.validate(%{"age" => "30", "active" => "true"}, schema, convert: true)
# => {:ok, %{"age" => 30, "active" => true, "tags" => nil}}
Type matrix
What gets coerced?
| Type | Example input | Output (convert: true) | Notes |
|---|---|---|---|
| Number | "42", "3.14" |
42, 3.14 |
Trims whitespace first; invalid strings raise type errors. |
| Boolean | "true", "0", "off" |
true, false |
Uses your :truthy/:falsy lists or ExJoi defaults. |
| Date | "2025-01-01T12:00:00Z" |
DateTime in UTC |
Accepts full ISO8601; falls back to NaiveDateTime if needed. |
| Array | "ana,bea,clara" |
["ana","bea","clara"] |
Control splitting via :delimiter (default comma). |
Enabling convert mode
Per-request control
def create(conn, params) do
convert? = conn.assigns[:accepts_loose_payloads?] || false
case ExJoi.validate(params, schema(), convert: convert?) do
{:ok, normalized} ->
Users.create(normalized)
{:error, %{errors_flat: flat}} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: flat})
end
end
This pattern keeps strict validation as the default and elevates convert mode only when upstream systems send loosely typed payloads (REST clients, CSV imports, etc.).
Detailed conversion rules
How each type is converted
Understanding the exact behavior helps you write predictable schemas.
String to Number
# Valid conversions
ExJoi.validate(%{"age" => "42"}, schema, convert: true)
# {:ok, %{"age" => 42}}
ExJoi.validate(%{"price" => "99.99"}, schema, convert: true)
# {:ok, %{"price" => 99.99}}
# Invalid strings fail
ExJoi.validate(%{"age" => "not-a-number"}, schema, convert: true)
# {:error, %{errors_flat: %{"age" => ["must be a number"]}}}
# Whitespace is trimmed first
ExJoi.validate(%{"age" => " 42 "}, schema, convert: true)
# {:ok, %{"age" => 42}}
# Empty strings fail (not converted to 0)
ExJoi.validate(%{"age" => ""}, schema, convert: true)
# {:error, %{errors_flat: %{"age" => ["must be a number"]}}}
String to Boolean
# Default truthy: ["true", "1", "yes", "on"]
# Default falsy: ["false", "0", "no", "off", ""]
schema = ExJoi.schema(%{
active: ExJoi.boolean(required: true)
})
# All convert to true
ExJoi.validate(%{"active" => "true"}, schema, convert: true)
ExJoi.validate(%{"active" => "1"}, schema, convert: true)
ExJoi.validate(%{"active" => "yes"}, schema, convert: true)
ExJoi.validate(%{"active" => "on"}, schema, convert: true)
# {:ok, %{"active" => true}}
# All convert to false
ExJoi.validate(%{"active" => "false"}, schema, convert: true)
ExJoi.validate(%{"active" => "0"}, schema, convert: true)
ExJoi.validate(%{"active" => "no"}, schema, convert: true)
ExJoi.validate(%{"active" => "off"}, schema, convert: true)
ExJoi.validate(%{"active" => ""}, schema, convert: true)
# {:ok, %{"active" => false}}
# Custom truthy/falsy lists
schema = ExJoi.schema(%{
enabled: ExJoi.boolean(
truthy: ["enabled", "active", "1"],
falsy: ["disabled", "inactive", "0"]
)
})
String to Date
schema = ExJoi.schema(%{
created_at: ExJoi.date(required: true)
})
# Full ISO8601 with timezone
ExJoi.validate(%{"created_at" => "2025-01-15T10:30:00Z"}, schema, convert: true)
# {:ok, %{"created_at" => ~U[2025-01-15 10:30:00Z]}}
# ISO8601 with offset
ExJoi.validate(%{"created_at" => "2025-01-15T10:30:00+05:00"}, schema, convert: true)
# {:ok, %{"created_at" => ~U[2025-01-15 05:30:00Z]}}
# Date only (becomes NaiveDateTime at midnight)
ExJoi.validate(%{"created_at" => "2025-01-15"}, schema, convert: true)
# {:ok, %{"created_at" => ~N[2025-01-15 00:00:00]}}
# Invalid formats fail
ExJoi.validate(%{"created_at" => "January 15, 2025"}, schema, convert: true)
# {:error, %{errors_flat: %{"created_at" => ["must be ISO8601"]}}}
String to Array
# Default delimiter is comma
schema = ExJoi.schema(%{
tags: ExJoi.array(of: ExJoi.string(min: 2))
})
ExJoi.validate(%{"tags" => "elixir,phoenix,ecto"}, schema, convert: true)
# {:ok, %{"tags" => ["elixir", "phoenix", "ecto"]}}
# Whitespace is trimmed from each item
ExJoi.validate(%{"tags" => " elixir , phoenix , ecto "}, schema, convert: true)
# {:ok, %{"tags" => ["elixir", "phoenix", "ecto"]}}
# Empty strings become empty arrays
ExJoi.validate(%{"tags" => ""}, schema, convert: true)
# {:ok, %{"tags" => []}}
# Custom delimiter
schema = ExJoi.schema(%{
permissions: ExJoi.array(
of: ExJoi.string(min: 3),
delimiter: ";"
)
})
ExJoi.validate(%{"permissions" => "read;write;admin"}, schema, convert: true)
# {:ok, %{"permissions" => ["read", "write", "admin"]}}
# Pipe delimiter
schema = ExJoi.schema(%{
categories: ExJoi.array(
of: ExJoi.string(),
delimiter: "|"
)
})
ExJoi.validate(%{"categories" => "tech|business|design"}, schema, convert: true)
# {:ok, %{"categories" => ["tech", "business", "design"]}}
String Normalization
# In convert mode, strings are trimmed and normalized
schema = ExJoi.schema(%{
name: ExJoi.string(required: true, min: 2)
})
# Leading/trailing whitespace is trimmed
ExJoi.validate(%{"name" => " John "}, schema, convert: true)
# {:ok, %{"name" => "John"}}
# Multiple spaces are collapsed to single space
ExJoi.validate(%{"name" => "John Doe"}, schema, convert: true)
# {:ok, %{"name" => "John Doe"}}
# Newlines and tabs are normalized
ExJoi.validate(%{"name" => "John\n\tDoe"}, schema, convert: true)
# {:ok, %{"name" => "John Doe"}
# Empty strings after trimming still fail if required
ExJoi.validate(%{"name" => " "}, schema, convert: true)
# {:error, %{errors_flat: %{"name" => ["is required"]}}}
When to use convert mode
Best practices and use cases
✅ Use convert mode for:
- Form submissions: HTML forms send everything as strings. Convert mode handles this gracefully.
- CSV imports: CSV files are string-based. Convert mode makes importing numeric and boolean data seamless.
- External APIs: Many REST APIs send loosely typed JSON. Convert mode normalizes the data.
- Query parameters: URL query strings are always strings. Convert mode handles pagination, filters, etc.
- Webhook payloads: Third-party webhooks often send string numbers and booleans.
❌ Avoid convert mode for:
- Internal APIs: If you control both ends, keep strict typing for better error detection.
- Database records: Ecto already handles types. Don't convert again.
- Message queues: Use structured formats (JSON with proper types) for better reliability.
- Configuration files: Config should be properly typed from the start.
Advanced scenarios
Complex conversion patterns
schema = ExJoi.schema(%{
page: ExJoi.number(min: 1, integer: true),
per_page: ExJoi.number(min: 1, max: 100, integer: true),
active: ExJoi.boolean(),
tags: ExJoi.array(of: ExJoi.string(), delimiter: ","),
created_at: ExJoi.date()
}, defaults: %{
page: 1,
per_page: 20,
active: true
})
# Partial data with string values
params = %{
"page" => "2",
"per_page" => "50",
"active" => "false",
"tags" => "elixir,phoenix",
"created_at" => "2025-01-15T10:00:00Z"
}
{:ok, normalized} = ExJoi.validate(params, schema, convert: true)
# normalized = %{
# "page" => 2,
# "per_page" => 50,
# "active" => false,
# "tags" => ["elixir", "phoenix"],
# "created_at" => ~U[2025-01-15 10:00:00Z]
# }
# Missing fields get defaults
params = %{"page" => "3"}
{:ok, normalized} = ExJoi.validate(params, schema, convert: true)
# normalized = %{
# "page" => 3,
# "per_page" => 20, # from defaults
# "active" => true # from defaults
# }
schema = ExJoi.schema(%{
user: ExJoi.object(%{
age: ExJoi.number(min: 18, integer: true),
active: ExJoi.boolean(),
preferences: ExJoi.object(%{
notifications: ExJoi.boolean(),
theme: ExJoi.string(in: ["light", "dark"])
})
}),
items: ExJoi.array(
of: ExJoi.object(%{
quantity: ExJoi.number(min: 1, integer: true),
price: ExJoi.number(min: 0)
}),
delimiter: ";"
)
})
params = %{
"user" => %{
"age" => "25",
"active" => "true",
"preferences" => %{
"notifications" => "false",
"theme" => "dark"
}
},
"items" => "quantity:5,price:99.99;quantity:2,price:49.99"
}
# Note: Nested object conversions work, but array delimiter
# only applies to top-level string-to-array conversion
# For nested arrays, you'd need to handle the string format differently
Gotchas and edge cases
Stay intentional
1. Empty strings still fail
Convert mode trims but never turns empty strings into nil. Use defaults if you want that behavior.
# This fails
ExJoi.validate(%{"age" => ""}, schema, convert: true)
# {:error, %{errors_flat: %{"age" => ["must be a number"]}}}
# Use defaults instead
schema = ExJoi.schema(%{
age: ExJoi.number()
}, defaults: %{age: nil})
2. Array delimiters matter
Explicitly set :delimiter when accepting semi-colon or pipe separated lists. The default is comma.
# Wrong delimiter = wrong result
schema = ExJoi.schema(%{
items: ExJoi.array(of: ExJoi.string(), delimiter: ",")
})
ExJoi.validate(%{"items" => "a;b;c"}, schema, convert: true)
# {:ok, %{"items" => ["a;b;c"]}} # Single item, not split!
3. Custom validators get normalized values
ExJoi runs conversions before handing data to ExJoi.custom/2, so your validator can rely on typed inputs.
ExJoi.extend(:positive_int, fn value, _ctx ->
# value is already a number (if convert: true was used)
if is_integer(value) and value > 0 do
{:ok, value}
else
{:error, [%{code: :positive_int, message: "must be a positive integer"}]}
end
end)
4. Boolean conversion is case-sensitive
By default, only lowercase strings match truthy/falsy lists. "True" and "TRUE" won't convert unless you add them to your custom lists.
5. Date parsing is strict
Only ISO8601 formats are accepted. Common formats like "MM/DD/YYYY" or "DD-MM-YYYY" will fail. You'll need a custom validator for those.