Exdantic
View SourceA powerful, flexible schema definition and validation library for Elixir, inspired by Python's Pydantic.
This project is directly based on Elixact by LiboShen.
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
, andRootModel
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
- Field Validation: Individual field type and constraint checking
- Model Validation: Cross-field validation and data transformation
- Computed Fields: Generation of derived fields
- 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 lengthmax_length(n)
- Maximum string lengthformat(regex)
- String must match regex patternchoices(list)
- String must be one of the provided choices
Numeric Constraints (Integer/Float)
gt(n)
- Greater thanlt(n)
- Less thangteq(n)
- Greater than or equal tolteq(n)
- Less than or equal tochoices(list)
- Number must be one of the provided choices
Array Constraints
min_items(n)
- Minimum number of itemsmax_items(n)
- Maximum number of items
Field Modifiers
required()
- Field is required (default)optional()
- Field is optionaldefault(value)
- Default value if not provided (implies optional)description(text)
- Field descriptionexample(value)
- Example value for documentationexamples(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
- Reuse Schemas and Adapters: Create once, use many times
- Use Appropriate Configuration: Avoid
:strict
if not needed - Batch Operations: Use
validate_many
for multiple items - Cache JSON Schemas: Generate once for repeated use
- 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
basic_usage.exs
- Core concepts and fundamental patternscustom_validation.exs
- Business logic and custom validatorsadvanced_features.exs
- Complex validation patterns
π€ LLM & DSPy Integration
llm_integration.exs
- LLM output validationdspy_integration.exs
- Complete DSPy patternsllm_pipeline_orchestration.exs
- Multi-stage pipelines
β‘ Runtime Features
runtime_schema.exs
- Dynamic schema creationtype_adapter.exs
- Runtime type validationwrapper_models.exs
- Single-field validation
π§ Advanced Features
model_validators.exs
- Cross-field validationcomputed_fields.exs
- Derived fieldsroot_schema.exs
- Non-dictionary validation
Run any example:
mix run examples/basic_usage.exs
See the complete guide: examples/README.md
π€ Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Write tests for your changes
- Ensure all tests pass (
mix test
) - Run quality checks:
mix format mix credo --strict mix dialyzer
- Commit your changes (
git commit -am 'Add amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - 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 π