Schemas & Validation

View Source

This guide covers the schema and validation system in Jido Action, including both NimbleOptions and Zoi schemas, how they're used for input/output validation, and how they power AI tool integration.

Overview

Jido Action supports two schema backends:

BackendBest ForReturns
NimbleOptionsSimple schemas, familiar Elixir patternsmap()
ZoiComplex validation, transformations, refinementsstruct() (converted to map)

Both are fully supported and can be used interchangeably. The Jido.Action.Schema adapter provides a unified interface.

NimbleOptions Schemas

NimbleOptions is the traditional choice for Elixir configuration validation. Use keyword lists to define your schema:

defmodule MyApp.Actions.CreateUser do
  use Jido.Action,
    name: "create_user",
    description: "Creates a new user account",
    schema: [
      email: [
        type: :string,
        required: true,
        doc: "User's email address"
      ],
      name: [
        type: :string,
        required: true,
        doc: "User's full name"
      ],
      age: [
        type: :integer,
        required: false,
        doc: "User's age"
      ],
      role: [
        type: {:in, [:admin, :user, :guest]},
        default: :user,
        doc: "User role"
      ]
    ]

  @impl true
  def run(params, _context) do
    # params is a map: %{email: "...", name: "...", role: :user}
    {:ok, %{user_id: generate_id(), email: params.email}}
  end
  
  defp generate_id, do: Uniq.UUID.uuid7()
end

Supported NimbleOptions Types

TypeDescription
:stringString values
:integerInteger values
:floatFloat values
:booleanBoolean values
:atomAtom values
:mapMap values
{:in, [...]}Enumerated values
{:list, type}List of specified type
:keyword_listKeyword list

NimbleOptions Options

OptionDescription
required: trueField must be provided
default: valueDefault value if not provided
doc: "..."Documentation (appears in AI tool schemas)
keys: [...]For nested keyword lists

Zoi provides advanced validation with built-in transformations and refinements. Use Zoi when you need:

  • Input transformations (trim, case conversion)
  • Custom validation logic
  • Complex nested structures
  • Type coercion
defmodule MyApp.Actions.RegisterUser do
  use Jido.Action,
    name: "register_user",
    description: "Registers a new user with validation",
    schema: Zoi.object(%{
      email: Zoi.string()
             |> Zoi.trim()
             |> Zoi.to_downcase()
             |> Zoi.regex(Zoi.Regexes.email(), message: "Invalid email"),
      password: Zoi.string()
                |> Zoi.min(8, message: "Password must be at least 8 characters")
                |> Zoi.regex(~r/[A-Z]/, message: "Must contain uppercase")
                |> Zoi.regex(~r/[0-9]/, message: "Must contain digit"),
      name: Zoi.string() |> Zoi.trim() |> Zoi.min(1),
      age: Zoi.integer() |> Zoi.min(13) |> Zoi.max(120) |> Zoi.optional(),
      role: Zoi.enum([:user, :admin]) |> Zoi.default(:user)
    })

  @impl true
  def run(params, _context) do
    # params.email is already trimmed and lowercased
    # params.password passed all regex checks
    # params.age is nil if not provided
    {:ok, %{registered: true, email: params.email}}
  end
end

Zoi Transformations

Transformations modify the input before validation:

Zoi.string()
|> Zoi.trim()           # Remove whitespace
|> Zoi.to_downcase()    # Convert to lowercase
|> Zoi.to_upcase()      # Convert to uppercase

Zoi Validators

# String constraints
Zoi.string() |> Zoi.min(1) |> Zoi.max(100)
Zoi.string() |> Zoi.regex(~r/^[a-z]+$/)
Zoi.string() |> Zoi.email()  # Built-in email validation

# Number constraints
Zoi.integer() |> Zoi.min(0) |> Zoi.max(100)
Zoi.float() |> Zoi.positive()

# Enums
Zoi.enum([:low, :medium, :high])

Custom Refinements

Add custom validation logic with Zoi.refine/2:

schema: Zoi.object(%{
  password: Zoi.string()
            |> Zoi.min(8)
            |> Zoi.refine(fn password ->
              if String.contains?(password, ["password", "123456"]) do
                {:error, "Password is too common"}
              else
                :ok
              end
            end),
  confirm_password: Zoi.string()
})
|> Zoi.refine(fn params ->
  if params.password == params.confirm_password do
    :ok
  else
    {:error, "Passwords do not match"}
  end
end)

Nested Objects

schema: Zoi.object(%{
  user: Zoi.object(%{
    email: Zoi.string() |> Zoi.email(),
    profile: Zoi.object(%{
      bio: Zoi.string() |> Zoi.optional(),
      website: Zoi.string() |> Zoi.optional()
    }) |> Zoi.optional()
  }),
  settings: Zoi.object(%{
    notifications: Zoi.boolean() |> Zoi.default(true)
  })
})

Output Schemas

Both NimbleOptions and Zoi can validate action output:

defmodule MyApp.Actions.ProcessData do
  use Jido.Action,
    name: "process_data",
    schema: [
      input: [type: :string, required: true]
    ],
    output_schema: [
      result: [type: :string, required: true],
      processed_at: [type: :integer, required: true]
    ]

  @impl true
  def run(params, _context) do
    {:ok, %{
      result: String.upcase(params.input),
      processed_at: System.system_time(:second)
    }}
  end
end

With Zoi:

output_schema: Zoi.object(%{
  result: Zoi.string(),
  count: Zoi.integer() |> Zoi.min(0),
  status: Zoi.enum([:success, :partial, :failed])
})

The Schema Adapter (Jido.Action.Schema)

Behind the scenes, Jido.Action.Schema provides a unified interface:

# Detect schema type
Jido.Action.Schema.schema_type(my_schema)
# => :nimble | :zoi | :empty

# Validate data
{:ok, validated} = Jido.Action.Schema.validate(schema, params)

# Get known keys (for partial validation)
keys = Jido.Action.Schema.known_keys(schema)
# => [:email, :name, :age]

# Convert to JSON Schema (for AI tools)
json_schema = Jido.Action.Schema.to_json_schema(schema)

Open/Partial Validation

Important: Jido uses "open" validation semantics. Only fields declared in the schema are validated; extra fields pass through unchanged.

defmodule MyApp.Actions.Step1 do
  use Jido.Action,
    name: "step1",
    schema: [
      input: [type: :string, required: true]
    ]

  def run(params, _context) do
    # params may contain extra fields from previous actions
    {:ok, Map.put(params, :step1_done, true)}
  end
end

This enables action composition where data flows through a chain:

# Step1 validates :input, ignores :user_id
# Step2 validates :step1_done, ignores :input and :user_id
# All fields are preserved through the chain
{:ok, result} = Jido.Exec.Chain.chain(
  [Step1, Step2, Step3],
  %{input: "data", user_id: 123}
)

AI Tool Integration

Schemas automatically power AI tool generation. The doc (NimbleOptions) or description (Zoi) fields appear in the tool schema:

defmodule MyApp.Actions.SearchProducts do
  use Jido.Action,
    name: "search_products",
    description: "Search for products in the catalog",
    schema: [
      query: [type: :string, required: true, doc: "Search query string"],
      category: [type: {:in, ["electronics", "clothing", "home"]}, doc: "Product category"],
      max_results: [type: :integer, default: 10, doc: "Maximum results to return"]
    ]
  
  # ...
end

# Convert to AI tool format
tool = MyApp.Actions.SearchProducts.to_tool()
# => %{
#      name: "search_products",
#      description: "Search for products in the catalog",
#      function: #Function<...>,
#      parameters_schema: %{
#        "type" => "object",
#        "properties" => %{
#          "query" => %{"type" => "string", "description" => "Search query string"},
#          "category" => %{"type" => "string", "enum" => ["electronics", "clothing", "home"], ...},
#          "max_results" => %{"type" => "integer", "description" => "Maximum results to return"}
#        },
#        "required" => ["query"]
#      }
#    }

See the AI Integration Guide for more details.

Error Handling

Validation errors are wrapped in Jido.Action.Error.InvalidInputError:

case Jido.Exec.run(MyAction, %{invalid: "params"}) do
  {:ok, result} -> 
    result
    
  {:error, %Jido.Action.Error.InvalidInputError{} = error} ->
    # error.message contains a human-readable description
    # error.details may contain field-specific info
    Logger.error("Validation failed: #{Exception.message(error)}")
end

For Zoi schemas, errors include path information:

# Error for nested field
%{
  path: [:user, :email],
  message: "Invalid email format",
  code: :invalid_format
}

Choosing Between NimbleOptions and Zoi

Use NimbleOptions WhenUse Zoi When
Simple flat schemasComplex nested structures
No input transformation neededNeed trim/case conversion
Familiar with NimbleOptionsNeed custom refinements
Minimal dependenciesWant rich error messages
Config-style validationForm/API input validation

Example: Complete Zoi Action

See Jido.Tools.ZoiExample for a production-quality example demonstrating all Zoi features:

# Run the example
params = %{
  user: %{
    email: "  JOHN@EXAMPLE.COM  ",  # Will be trimmed and lowercased
    password: "SecurePass123!",
    name: "John Doe"
  },
  priority: :high
}

{:ok, result} = Jido.Exec.run(Jido.Tools.ZoiExample, params)
result.user.email  # => "john@example.com"
result.status      # => :approved (high priority)

Best Practices

  1. Document your schemas - Use doc: (NimbleOptions) or description: (Zoi) for AI integration
  2. Validate outputs - Use output_schema to catch bugs early
  3. Transform at the boundary - Use Zoi transformations to normalize input
  4. Keep schemas focused - Only validate what your action needs; let extra fields pass through
  5. Use refinements for business logic - Keep complex validation in Zoi.refine/2, not in run/2