Guide
Conditional Validation with ExJoi.when/3
Conditional rules let you branch validation logic based on other fields, value ranges, or regex patterns. This is how you build role-based permissions, dynamic forms, and context-aware pipelines without resorting to custom code.
API Reference
ExJoi.when/3 - Complete Reference
Detailed documentation for the conditional validation function.
Function Signature
ExJoi.when(other_field, condition_opts, default_rule \\ nil)
Parameters
other_field (atom, required)
The field name (as an atom) to check against. This field must be defined earlier in the schema map.
# ✅ Correct: age is defined before guardian_name
schema = ExJoi.schema(%{
age: ExJoi.number(required: true),
guardian_name: ExJoi.when(:age, max: 17, then: ExJoi.string(required: true))
})
# ❌ Wrong: guardian_name references age before it's defined
schema = ExJoi.schema(%{
guardian_name: ExJoi.when(:age, max: 17, then: ExJoi.string(required: true)),
age: ExJoi.number(required: true)
})
condition_opts (keyword list, required)
A keyword list containing the condition to check and the rules to apply.
Required Options
:then(%ExJoi.Rule{}, required) - Rule applied when condition matches
Condition Options (at least one required)
:is(any) - Exact match against the other field's value:in(list) - Match when the other field's value is in the provided list:matches(Regex) - Match when the other field (string) satisfies the regex pattern:min(number) - Match when the other field (number) is greater than or equal to this value:max(number) - Match when the other field (number) is less than or equal to this value
Optional Options
:otherwise(%ExJoi.Rule{}) - Rule applied when condition does not match:required(boolean, default:false) - Whether the field itself is required regardless of conditions
default_rule (%ExJoi.Rule{}, optional)
A base rule used when no :otherwise is provided in condition_opts. This provides a fallback when the condition doesn't match.
# Using default_rule parameter
permissions: ExJoi.when(
:role,
[is: "admin", then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true)],
ExJoi.array(of: ExJoi.string()) # default_rule
)
# Equivalent using :otherwise
permissions: ExJoi.when(
:role,
[
is: "admin",
then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true),
otherwise: ExJoi.array(of: ExJoi.string())
]
)
Returns
%ExJoi.Rule{type: :conditional, conditional: %{...}}
A rule struct with type :conditional containing the condition logic and branch rules.
Condition Types Explained
:is - Exact Match
Matches when the other field equals the given value exactly (uses == comparison).
permissions: ExJoi.when(
:role,
is: "admin",
then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true)
)
# Matches when role == "admin"
:in - List Membership
Matches when the other field's value is present in the provided list.
billing_email: ExJoi.when(
:plan,
in: ["pro", "enterprise"],
then: ExJoi.string(required: true, email: true)
)
# Matches when plan is "pro" OR "enterprise"
:matches - Regex Pattern
Matches when the other field (must be a string) satisfies the provided regex pattern.
pro_feature: ExJoi.when(
:plan,
matches: ~r/^pro/i,
then: ExJoi.boolean(required: true)
)
# Matches when plan matches /^pro/i (case-insensitive)
:min / :max - Numeric Range
Matches when the other field (must be a number) falls within the inclusive range.
guardian_name: ExJoi.when(
:age,
max: 17,
then: ExJoi.string(required: true, min: 2)
)
# Matches when age <= 17
discount: ExJoi.when(
:quantity,
min: 10,
then: ExJoi.number(min: 0.1, max: 0.5)
)
# Matches when quantity >= 10
Multiple Conditions
You can provide multiple conditions in the same condition_opts. All conditions must pass for the :then branch to apply.
# Both :is and :min must be true
permissions: ExJoi.when(
:role,
[
is: "admin",
min: 1, # role must be numeric and >= 1 (unlikely, but shows the concept)
then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true)
]
)
# Note: Multiple conditions are ANDed together
Error Handling
Missing Condition
If no condition option is provided, an ArgumentError is raised:
# ❌ This will raise ArgumentError
ExJoi.when(:role, [then: ExJoi.string()])
# => ** (ArgumentError) ExJoi.when/3 requires at least one condition
Missing :then
If :then is not provided, a KeyError is raised:
# ❌ This will raise KeyError
ExJoi.when(:role, [is: "admin"])
# => ** (KeyError) key :then not found
Condition options
Everything you can check
| Option | Description | Example |
|---|---|---|
:is |
Exact match against the other field. | is: "admin" |
:in |
Any match within a list or range. | in: ["editor", "publisher"] |
:matches |
Regex pattern applied to strings. | matches: ~r/^pro/i |
:min / :max |
Numeric comparisons on the other field. | max: 17 (guardian required if under 18) |
Branching
Combining :then / :otherwise
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())
]
)
If you omit :otherwise, ExJoi falls back to the optional third argument you pass into when/3 (handy for base rules).
Advanced scenario
Multiple conditions + nested objects
schema =
ExJoi.schema(%{
plan: ExJoi.string(required: true),
usage: ExJoi.number(required: true, min: 0),
billing:
ExJoi.when(
:plan,
[
matches: ~r/^pro/i,
then:
ExJoi.object(%{
invoice_email: ExJoi.string(required: true, email: true),
net_terms: ExJoi.number(min: 0, max: 30)
}),
otherwise:
ExJoi.object(%{
card_last4: ExJoi.string(required: true, min: 4, max: 4)
})
]
),
guardian_contact:
ExJoi.when(:usage, max: 99, then: ExJoi.string())
})
Conditions run sequentially, so you can nest ExJoi.when/3 inside arrays, objects, or even other conditional branches. Keep them readable by extracting helper functions once they get large.
Real-world examples
Common conditional patterns
See how conditional rules solve real validation challenges.
schema = ExJoi.schema(%{
age: ExJoi.number(required: true, min: 0, max: 120, integer: true),
guardian_name: ExJoi.when(
:age,
max: 17,
then: ExJoi.string(required: true, min: 2, max: 100),
otherwise: ExJoi.string()
),
guardian_email: ExJoi.when(
:age,
max: 17,
then: ExJoi.string(required: true, email: true),
otherwise: ExJoi.string(email: true)
)
})
# Under 18: guardian required
ExJoi.validate(%{"age" => 16}, schema)
# {:error, %{errors_flat: %{"guardian_name" => ["is required"], "guardian_email" => ["is required"]}}}
# 18 or older: guardian optional
ExJoi.validate(%{"age" => 18}, schema)
# {:ok, %{"age" => 18}}
schema = ExJoi.schema(%{
plan: ExJoi.string(required: true, in: ["free", "pro", "enterprise"]),
billing_email: ExJoi.when(
:plan,
in: ["pro", "enterprise"],
then: ExJoi.string(required: true, email: true),
otherwise: ExJoi.string(email: true)
),
payment_method: ExJoi.when(
:plan,
is: "enterprise",
then: ExJoi.object(%{
type: ExJoi.string(required: true, in: ["invoice", "ach", "wire"]),
net_terms: ExJoi.number(min: 0, max: 90, integer: true)
}, required: true),
otherwise: ExJoi.object(%{
type: ExJoi.string(required: true, in: ["card", "paypal"]),
card_last4: ExJoi.string(required: true, min: 4, max: 4)
})
),
team_size: ExJoi.when(
:plan,
matches: ~r/^(pro|enterprise)$/,
then: ExJoi.number(required: true, min: 1, integer: true),
otherwise: ExJoi.number(min: 1, integer: true)
)
})
schema = ExJoi.schema(%{
account_type: ExJoi.string(required: true, in: ["personal", "business"]),
business_name: ExJoi.when(
:account_type,
is: "business",
then: ExJoi.string(required: true, min: 2, max: 100),
otherwise: ExJoi.string()
),
tax_id: ExJoi.when(
:account_type,
is: "business",
then: ExJoi.string(required: true, pattern: ~r/^\d{2}-\d{7}$/),
otherwise: ExJoi.string()
),
personal_id: ExJoi.when(
:account_type,
is: "personal",
then: ExJoi.string(required: true, min: 5, max: 20),
otherwise: ExJoi.string()
)
})
Nested conditionals
Complex branching logic
You can nest conditionals inside objects, arrays, and even other conditionals.
schema = ExJoi.schema(%{
user_type: ExJoi.string(required: true, in: ["student", "teacher", "admin"]),
profile: ExJoi.object(%{
name: ExJoi.string(required: true, min: 2),
email: ExJoi.string(required: true, email: true),
school_info: ExJoi.when(
:user_type,
in: ["student", "teacher"],
then: ExJoi.object(%{
school_name: ExJoi.string(required: true),
grade_level: ExJoi.when(
:user_type,
is: "student",
then: ExJoi.number(required: true, min: 1, max: 12, integer: true),
otherwise: ExJoi.number(min: 1, max: 12, integer: true)
),
subjects: ExJoi.when(
:user_type,
is: "teacher",
then: ExJoi.array(
of: ExJoi.string(min: 2),
min_items: 1,
required: true
),
otherwise: ExJoi.array(of: ExJoi.string(min: 2))
)
}, required: true),
otherwise: ExJoi.object(%{
organization: ExJoi.string(required: true),
department: ExJoi.string(required: true)
}, required: true)
)
})
})
Condition evaluation order
How conditions are checked
Understanding evaluation order helps you write predictable conditional rules.
1. Field value is resolved first
The field referenced in when/3 must be validated before the conditional rule. ExJoi validates fields in the order they appear in the schema map.
# ✅ Good: age is defined before guardian_name
schema = ExJoi.schema(%{
age: ExJoi.number(required: true),
guardian_name: ExJoi.when(:age, max: 17, then: ExJoi.string(required: true))
})
# ⚠️ Problem: guardian_name references age before it's validated
schema = ExJoi.schema(%{
guardian_name: ExJoi.when(:age, max: 17, then: ExJoi.string(required: true)),
age: ExJoi.number(required: true)
})
2. Conditions are evaluated in order
When multiple conditions are provided (like is and min), they're all checked. All must pass for the :then branch to apply.
# Both conditions must be true
permissions: ExJoi.when(
:role,
[is: "admin", min: 1], # role must be "admin" AND >= 1 (if numeric)
then: ExJoi.array(of: ExJoi.string(), min_items: 1, required: true)
)
3. First matching condition wins
If you have multiple when/3 rules for the same field, they're evaluated in schema order. The first match applies.
Best practices
Writing maintainable conditionals
1. Extract complex conditionals to functions
When conditionals get long, extract them to helper functions for readability.
defmodule MyApp.Schemas.User do
defp admin_permissions_rule do
ExJoi.array(
of: ExJoi.string(min: 3),
min_items: 1,
required: true
)
end
defp regular_permissions_rule do
ExJoi.array(of: ExJoi.string(min: 3))
end
def schema do
ExJoi.schema(%{
role: ExJoi.string(required: true),
permissions: ExJoi.when(
:role,
is: "admin",
then: admin_permissions_rule(),
otherwise: regular_permissions_rule()
)
})
end
end
2. Use descriptive field names
Clear field names make conditionals self-documenting.
3. Test all branches
Write tests for each conditional branch to ensure they work as expected.
test "requires guardian for minors" do
params = %{"age" => 16}
assert {:error, %{errors_flat: errors}} = ExJoi.validate(params, schema)
assert Map.has_key?(errors, "guardian_name")
end
test "makes guardian optional for adults" do
params = %{"age" => 18}
assert {:ok, _} = ExJoi.validate(params, schema)
end
4. Avoid deeply nested conditionals
If you find yourself nesting 3+ levels deep, consider splitting into multiple schemas or using custom validators.
Troubleshooting
Common issues and solutions
Conditional not triggering
Problem: The :then branch never applies even when the condition should match.
Solution: Check that the referenced field is validated before the conditional rule. Also verify the condition values match exactly (case-sensitive for strings).
Wrong branch executing
Problem: The :otherwise branch runs when you expect :then.
Solution: Verify the condition type matches. For example, is: "admin" won't match if the field value is an atom :admin.
Required field in wrong branch
Problem: A field is required when it shouldn't be, or optional when it should be required.
Solution: Make sure required: true is only in the :then branch where you want it required, not in :otherwise.