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.

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

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)

Combining :then / :otherwise

Role-based rules
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).

Multiple conditions + nested objects

Complex example
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.

Common conditional patterns

See how conditional rules solve real validation challenges.

Age-based validation
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}}
Subscription tiers
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)
  )
})
Multi-field conditions
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()
  )
})

Complex branching logic

You can nest conditionals inside objects, arrays, and even other conditionals.

Nested example
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)
    )
  })
})

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.

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.

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.