Three layers: type coercion (automatic), per-param validation (inline), and cross-param validation (after all args are parsed).

Per-param: choices

The cheapest form. Enumerate the accepted values:

option :format, type: :string, choices: ["json", "csv", "table"]

Rejected values produce error: --format must be one of: json, csv, table.

Also works on arguments:

argument :env, type: :string, choices: ["dev", "staging", "prod"]

Per-param: inline validator

Any function that returns :ok or {:error, message}:

option :port, type: :integer,
  validate: fn p ->
    if p in 1024..65_535, do: :ok, else: {:error, "port must be 1024-65535"}
  end

Runs after type coercion, so the value you receive is already the declared type.

Cross-param validators

Run after every option and argument has been parsed. Receives the full args map.

validate fn args ->
  cond do
    args[:tls] and is_nil(args[:cert]) -> {:error, "--tls requires --cert"}
    args[:since] && args[:until] && args[:since] > args[:until] ->
      {:error, "--since must be before --until"}
    true -> :ok
  end
end

You can declare multiple validate blocks per command. They run in declaration order; the first {:error, ...} halts the pipeline.

Order of evaluation

For a single invocation, validation runs in this order:

  1. Type coercion (automatic, based on :type).
  2. Defaults and env var fallback.
  3. Required-argument and required-option checks.
  4. Choices (:choices).
  5. Per-param :validate functions.
  6. Cross-param validators (validate fn args -> ... end).
  7. Conditional-required (:required_if, :required_unless).
  8. Per-option constraints (:conflicts_with, :requires).
  9. Group constraints (mutually_exclusive, co_occurring).

The first failure halts; your run/2 is only called if everything passes.

See also

  • Constraints for conditional-required, conflicts, requires, and groups.
  • Options and Arguments for the declarations validation operates on.