Exdantic

View Source

A powerful, flexible schema definition and validation library for Elixir, inspired by Python's Pydantic.

This project is directly based on Elixact by LiboShen.

CI Hex.pm Hex Docs

Exdantic provides a comprehensive toolset for data validation, serialization, and schema generation. Perfect for building robust APIs, managing complex configurations, and creating data processing pipelines. With advanced runtime features, Exdantic is uniquely suited for AI and LLM applications, enabling dynamic, DSPy-style programming patterns in Elixir.

Original Project

The original Elixact project can be found at: https://github.com/LiboShen/elixact

✨ Key Features

  • 🎯 Rich Type System: Support for basic types, complex nested structures (arrays, maps, unions), and custom types
  • πŸš€ Runtime & Compile-Time Schemas: Define schemas dynamically at runtime or at compile-time for maximum flexibility
  • πŸ—οΈ Struct Support: Optional struct generation for type-safe data structures with automatic serialization
  • πŸ”§ Model Validators: Cross-field validation and data transformation after field validation
  • ⚑ Computed Fields: Derive additional fields from validated data automatically
  • 🎨 Pydantic-Inspired Patterns: Support for create_model, TypeAdapter, Wrapper, and RootModel patterns
  • πŸ” Advanced Validation: Rich constraints plus custom validation functions
  • πŸ”„ Type Coercion: Automatic and configurable type coercion
  • πŸ“Š Advanced JSON Schema: Generate optimized JSON Schema for LLM providers (OpenAI, Anthropic)
  • 🚨 Structured Errors: Path-aware error messages for precise debugging
  • βš™οΈ Dynamic Configuration: Runtime configuration with preset patterns

πŸš€ Quick Start

Installation

Add exdantic to your list of dependencies in mix.exs:

def deps do
  [
    {:exdantic, "~> 0.0.1"}
  ]
end

Basic Schema Definition

defmodule UserSchema do
  use Exdantic, define_struct: true

  schema "User account information" do
    field :name, :string do
      required()
      min_length(2)
      description("User's full name")
    end

    field :email, :string do
      required()
      format(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
      description("Primary email address")
    end

    field :age, :integer do
      optional()
      gt(0)
      lt(150)
      description("User's age in years")
    end

    field :active, :boolean do
      default(true)
      description("Whether the account is active")
    end

    # Cross-field validation
    model_validator :validate_adult_email

    # Computed field derived from other fields
    computed_field :display_name, :string, :generate_display_name

    config do
      title("User Schema")
      strict(true)
    end
  end

  def validate_adult_email(input) do
    if input.age && input.age >= 18 && String.contains?(input.email, "example.com") do
      {:error, "Adult users cannot use example.com emails"}
    else
      {:ok, input}
    end
  end

  def generate_display_name(input) do
    display = if input.age do
      "#{input.name} (#{input.age})"
    else
      input.name
    end
    {:ok, display}
  end
end

# Validation returns struct instances when define_struct: true
case UserSchema.validate(%{
  name: "John Doe",
  email: "john@company.com",
  age: 30
}) do
  {:ok, %UserSchema{} = user} ->
    IO.puts("User: #{user.display_name}")
    # Outputs: "User: John Doe (30)"
    
  {:error, errors} ->
    Enum.each(errors, &IO.puts(Exdantic.Error.format(&1)))
end

Dynamic Runtime Schemas

Perfect for DSPy-style applications and dynamic validation:

# Define fields programmatically
fields = [
  {:reasoning, :string, [description: "Chain of thought reasoning"]},
  {:answer, :string, [required: true, min_length: 1]},
  {:confidence, :float, [required: true, gteq: 0.0, lteq: 1.0]},
  {:sources, {:array, :string}, [optional: true]}
]

# Create schema at runtime (like Pydantic's create_model)
llm_output_schema = Exdantic.Runtime.create_schema(fields, 
  title: "LLM_Output_Schema",
  description: "Schema for LLM structured output"
)

# Validate with coercion
config = Exdantic.Config.create(coercion: :safe, strict: true)
llm_response = %{
  "reasoning" => "Based on the context provided...",
  "answer" => "The answer is 42",
  "confidence" => "0.95"  # String that needs coercion to float
}

{:ok, validated} = Exdantic.EnhancedValidator.validate(
  llm_output_schema, 
  llm_response, 
  config: config
)

# Generate JSON Schema for LLM providers
json_schema = Exdantic.JsonSchema.EnhancedResolver.resolve_enhanced(
  llm_output_schema,
  optimize_for_provider: :openai,
  flatten_for_llm: true
)

TypeAdapter for Schemaless Validation

Validate individual values without full schema definition:

# Simple type validation with coercion
{:ok, 123} = Exdantic.TypeAdapter.validate(:integer, "123", coerce: true)

# Complex type validation
type_spec = {:array, {:map, {:string, {:union, [:string, :integer]}}}}
data = [
  %{"name" => "John", "score" => 85},
  %{"name" => "Jane", "score" => "92"}  # String score gets coerced
]

{:ok, validated} = Exdantic.TypeAdapter.validate(type_spec, data, coerce: true)

# Reusable TypeAdapter instances for performance
adapter = Exdantic.TypeAdapter.create({:array, :string}, coerce: true)
{:ok, results} = Exdantic.TypeAdapter.Instance.validate_many(adapter, [
  ["a", "b"],
  [1, 2],     # Numbers get coerced to strings
  ["c", "d"]
])

Wrapper Models for Single-Field Validation

Temporary schemas for complex type coercion patterns:

# Validate and extract a single complex value
{:ok, score} = Exdantic.Wrapper.wrap_and_validate(
  :test_score,
  :integer,
  "85",  # String input
  coerce: true,
  constraints: [gteq: 0, lteq: 100],
  description: "Test score out of 100"
)

# Multiple wrapper validation
wrappers = Exdantic.Wrapper.create_multiple_wrappers([
  {:name, :string, [constraints: [min_length: 1]]},
  {:age, :integer, [constraints: [gt: 0]]},
  {:score, :float, [constraints: [gteq: 0.0, lteq: 1.0]]}
])

data = %{name: "Test", age: "25", score: "0.95"}  # All strings
{:ok, validated} = Exdantic.Wrapper.validate_multiple(wrappers, data)
# All values are properly coerced to their target types

Root Schema for Non-Dictionary Validation

Validate non-dictionary types at the root level (similar to Pydantic's RootModel):

# Validate an array of integers
defmodule IntegerListSchema do
  use Exdantic.RootSchema, root: {:array, :integer}
end

{:ok, [1, 2, 3]} = IntegerListSchema.validate([1, 2, 3])

# Validate a string with constraints
defmodule EmailSchema do
  use Exdantic.RootSchema, 
    root: {:type, :string, [format: ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/]}
end

{:ok, "user@example.com"} = EmailSchema.validate("user@example.com")

# Validate union types
defmodule StringOrNumberSchema do
  use Exdantic.RootSchema, root: {:union, [:string, :integer]}
end

{:ok, "hello"} = StringOrNumberSchema.validate("hello")
{:ok, 42} = StringOrNumberSchema.validate(42)

# Validate arrays of complex schemas
defmodule UserListSchema do
  use Exdantic.RootSchema, root: {:array, UserSchema}
end

users = [%{name: "John", email: "john@example.com"}]
{:ok, validated_users} = UserListSchema.validate(users)

πŸ—οΈ Core Concepts

Schema Definition Approaches

Compile-Time Schemas (Best for static, performance-critical validation):

defmodule APIRequestSchema do
  use Exdantic, define_struct: true
  
  schema do
    field :action, :string, choices: ["create", "update", "delete"]
    field :resource_id, :integer, gt: 0
    field :data, :map, optional: true
    
    model_validator :validate_action_data_consistency
  end
end

Runtime Schemas (Best for dynamic validation, DSPy patterns):

# Schema created from field definitions at runtime
schema = Exdantic.Runtime.create_schema([
  {:query, :string, [required: true]},
  {:max_results, :integer, [optional: true, gt: 0, lteq: 100]}
])

Type System

Exdantic supports a comprehensive type system:

# Basic types
:string, :integer, :float, :boolean, :atom, :any, :map

# Complex types
{:array, inner_type}                    # Arrays
{:map, {key_type, value_type}}         # Maps with typed keys/values
{:union, [type1, type2, ...]}          # Union types
{:tuple, [type1, type2, ...]}          # Tuples

# Schema references
ModuleName                              # Reference to other schemas

# Custom types
MyCustomType                            # Modules implementing Exdantic.Type

Validation Pipeline

  1. Field Validation: Individual field type and constraint checking
  2. Model Validation: Cross-field validation and data transformation
  3. Computed Fields: Generation of derived fields
  4. Struct Creation: Optional struct instantiation (when define_struct: true)

Configuration System

Control validation behavior with dynamic configuration:

# Create configurations
strict_config = Exdantic.Config.create(strict: true, extra: :forbid)
lenient_config = Exdantic.Config.preset(:development)

# Builder pattern
config = Exdantic.Config.builder()
|> Exdantic.Config.Builder.strict(true)
|> Exdantic.Config.Builder.safe_coercion()
|> Exdantic.Config.Builder.for_api()
|> Exdantic.Config.Builder.build()

# Use with any validation
Exdantic.EnhancedValidator.validate(schema, data, config: config)

πŸ” Advanced Features

Model Validators

Cross-field validation and data transformation:

schema do
  field :password, :string, min_length: 8
  field :password_confirmation, :string
  
  # Named function validator
  model_validator :validate_passwords_match
  
  # Anonymous function validator  
  model_validator fn input ->
    if input.password == input.password_confirmation do
      # Transform: remove confirmation field
      {:ok, Map.delete(input, :password_confirmation)}
    else
      {:error, "Passwords do not match"}
    end
  end
end

Computed Fields

Automatically derive fields from validated data:

schema do
  field :first_name, :string, required: true
  field :last_name, :string, required: true
  field :email, :string, required: true
  
  # Named function computed field
  computed_field :full_name, :string, :generate_full_name
  
  # Anonymous function computed field
  computed_field :email_domain, :string, fn input ->
    domain = input.email |> String.split("@") |> List.last()
    {:ok, domain}
  end
end

def generate_full_name(input) do
  {:ok, "#{input.first_name} #{input.last_name}"}
end

Enhanced JSON Schema Generation

Generate optimized schemas for different use cases:

# Basic JSON Schema
json_schema = Exdantic.JsonSchema.from_schema(UserSchema)

# Enhanced schema with full metadata
enhanced_schema = Exdantic.JsonSchema.EnhancedResolver.resolve_enhanced(
  UserSchema,
  optimize_for_provider: :openai,
  include_model_validators: true,
  include_computed_fields: true
)

# DSPy-optimized schema
dspy_schema = Exdantic.JsonSchema.EnhancedResolver.optimize_for_dspy(
  UserSchema,
  signature_mode: true,
  field_descriptions: true,
  strict_types: true
)

# Comprehensive analysis
analysis = Exdantic.JsonSchema.EnhancedResolver.comprehensive_analysis(
  UserSchema,
  sample_data,
  test_llm_providers: [:openai, :anthropic, :generic]
)

Runtime Enhanced Schemas

Runtime schemas with model validators and computed fields:

# Enhanced runtime schema with full pipeline
fields = [{:name, :string, [required: true]}, {:age, :integer, [optional: true]}]

validators = [
  fn data -> {:ok, %{data | name: String.trim(data.name)}} end
]

computed_fields = [
  {:display_name, :string, fn data -> {:ok, String.upcase(data.name)} end}
]

enhanced_schema = Exdantic.Runtime.create_enhanced_schema(fields,
  model_validators: validators,
  computed_fields: computed_fields,
  title: "Enhanced User Schema"
)

{:ok, result} = Exdantic.Runtime.validate_enhanced(
  %{name: "  john  ", age: 25}, 
  enhanced_schema
)
# Result: %{name: "john", age: 25, display_name: "JOHN"}

πŸ“Š JSON Schema & LLM Integration

LLM Provider Optimization

Generate optimized schemas for different LLM providers:

# OpenAI Function Calling
openai_schema = Exdantic.JsonSchema.Resolver.enforce_structured_output(
  base_schema,
  provider: :openai,
  remove_unsupported: true
)

# Anthropic Tool Use
anthropic_schema = Exdantic.JsonSchema.Resolver.enforce_structured_output(
  base_schema,
  provider: :anthropic
)

# Resolve all references for simplified schemas
resolved_schema = Exdantic.JsonSchema.Resolver.resolve_references(complex_schema)

DSPy Integration Patterns

# Input schema (remove computed fields for input validation)
input_schema = Exdantic.JsonSchema.remove_computed_fields(full_schema)

# Output schema (include all fields for output validation)  
output_schema = Exdantic.JsonSchema.EnhancedResolver.optimize_for_dspy(
  full_schema,
  signature_mode: true
)

# DSPy-compatible configuration
dspy_config = Exdantic.Config.for_dspy(:signature, provider: :openai)

βš™οΈ Available Constraints

String Constraints

  • min_length(n) - Minimum string length
  • max_length(n) - Maximum string length
  • format(regex) - String must match regex pattern
  • choices(list) - String must be one of the provided choices

Numeric Constraints (Integer/Float)

  • gt(n) - Greater than
  • lt(n) - Less than
  • gteq(n) - Greater than or equal to
  • lteq(n) - Less than or equal to
  • choices(list) - Number must be one of the provided choices

Array Constraints

  • min_items(n) - Minimum number of items
  • max_items(n) - Maximum number of items

Field Modifiers

  • required() - Field is required (default)
  • optional() - Field is optional
  • default(value) - Default value if not provided (implies optional)
  • description(text) - Field description
  • example(value) - Example value for documentation
  • examples(list) - Multiple example values

🎨 Custom Types

Create reusable custom types:

defmodule Types.Email do
  use Exdantic.Type

  def type_definition do
    {:type, :string, [format: ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/]}
  end

  def json_schema do
    %{
      "type" => "string",
      "format" => "email",
      "pattern" => "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
    }
  end

  def validate(value) do
    case type_definition() |> Exdantic.Validator.validate(value) do
      {:ok, validated} -> {:ok, String.downcase(validated)}
      {:error, _} -> {:error, "Must be a valid email address"}
    end
  end
end

# Use the custom type
schema do
  field :email, Types.Email
  field :backup_email, Types.Email, optional: true
end

🚨 Error Handling

Exdantic provides structured, path-aware error information:

%Exdantic.Error{
  path: [:user, :address, :zip_code],  # Exact location of error
  code: :format,                       # Machine-readable error type
  message: "invalid zip code format"   # Human-readable message
}

# Format errors for display
case UserSchema.validate(invalid_data) do
  {:ok, validated} -> 
    validated
    
  {:error, errors} ->
    errors
    |> Enum.map(&Exdantic.Error.format/1)
    |> Enum.each(&IO.puts/1)
    # Outputs: "user.address.zip_code: invalid zip code format"
end

πŸ”§ Configuration Options

Validation Behavior

  • :strict - Enforce strict validation (no unknown fields)
  • :extra - Handle extra fields (:allow, :forbid, :ignore)
  • :coercion - Type coercion strategy (:none, :safe, :aggressive)
  • :frozen - Whether configuration is immutable
  • :validate_assignment - Validate field assignments

Error Handling

  • :error_format - Error detail level (:detailed, :simple, :minimal)
  • :case_sensitive - Case sensitivity for field names

Schema Generation

  • :use_enum_values - Use enum values instead of names
  • :max_anyof_union_len - Maximum length for anyOf unions
  • :title_generator - Function to generate field titles
  • :description_generator - Function to generate field descriptions

πŸ“ˆ Performance Guidelines

Best Practices

  1. Reuse Schemas and Adapters: Create once, use many times
  2. Use Appropriate Configuration: Avoid :strict if not needed
  3. Batch Operations: Use validate_many for multiple items
  4. Cache JSON Schemas: Generate once for repeated use
  5. Choose the Right Tool:
    • Compile-time schemas for static, performance-critical validation
    • Runtime schemas for dynamic validation
    • TypeAdapter for simple type validation
    • Wrapper for single-value coercion

Performance Characteristics

  • Simple validation: Sub-millisecond for basic types
  • Complex schemas: ~5-20ms for typical business objects
  • Runtime schema creation: ~1000 schemas/second
  • JSON schema generation: <50ms for complex schemas
  • Batch validation: ~10k items/second for simple types

πŸ—ΊοΈ Migration Guide

From Basic Validation Libraries

# Before: Manual validation
def validate_user(data) do
  with {:ok, name} <- validate_string(data["name"]),
       {:ok, age} <- validate_integer(data["age"]),
       {:ok, email} <- validate_email(data["email"]) do
    {:ok, %{name: name, age: age, email: email}}
  end
end

# After: Exdantic schema
defmodule UserSchema do
  use Exdantic
  
  schema do
    field :name, :string, required: true
    field :age, :integer, gt: 0
    field :email, :string, format: ~r/@/
  end
end

{:ok, user} = UserSchema.validate(data)

From Static to Dynamic Validation

# Static schema (compile-time)
defmodule APISchema do
  use Exdantic
  schema do
    field :endpoint, :string
    field :method, :string, choices: ["GET", "POST"]
  end
end

# Dynamic schema (runtime)
api_schema = Exdantic.Runtime.create_schema([
  {:endpoint, :string, [required: true]},
  {:method, :string, [choices: ["GET", "POST", "PUT", "DELETE"]]}
])

πŸ“š Examples

The examples/ directory contains comprehensive examples showcasing all of Exdantic's features:

🟒 Getting Started

πŸ€– LLM & DSPy Integration

⚑ Runtime Features

πŸ”§ Advanced Features

Run any example:

mix run examples/basic_usage.exs

See the complete guide: examples/README.md

🀝 Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Write tests for your changes
  4. Ensure all tests pass (mix test)
  5. Run quality checks:
    mix format
    mix credo --strict
    mix dialyzer
    
  6. Commit your changes (git commit -am 'Add amazing feature')
  7. Push to the branch (git push origin feature/amazing-feature)
  8. Open a Pull Request

Development Setup

# Clone the repository
git clone https://github.com/nshkrdotcom/exdantic.git
cd exdantic

# Install dependencies
mix deps.get

# Run tests
mix test

# Generate docs
mix docs

πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Acknowledgments

  • Inspired by Python's Pydantic library
  • DSPy integration patterns inspired by DSPy
  • Built with ❀️ for the Elixir community

Made with Elixir πŸ’œ