A 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.
The original Elixact project can be found at: https://github.com/LiboShen/elixact
- π― 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
Add exdantic
to your list of dependencies in mix.exs
:
def deps do
[
{:exdantic, "~> 0.0.1"}
]
end
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: "[email protected]",
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
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
)
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"]
])
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
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, "[email protected]"} = EmailSchema.validate("[email protected]")
# 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: "[email protected]"}]
{:ok, validated_users} = UserListSchema.validate(users)
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]}
])
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
- 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
)
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)
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
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
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 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"}
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)
# 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)
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
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
min_items(n)
- Minimum number of itemsmax_items(n)
- Maximum number of items
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
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
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
: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_format
- Error detail level (:detailed
,:simple
,:minimal
):case_sensitive
- Case sensitivity for field names
: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
- 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
- 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
# 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)
# 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"]]}
])
The examples/
directory contains comprehensive examples showcasing all of Exdantic's features:
basic_usage.exs
- Core concepts and fundamental patternscustom_validation.exs
- Business logic and custom validatorsadvanced_features.exs
- Complex validation patterns
llm_integration.exs
- LLM output validationdspy_integration.exs
- Complete DSPy patternsllm_pipeline_orchestration.exs
- Multi-stage pipelines
runtime_schema.exs
- Dynamic schema creationtype_adapter.exs
- Runtime type validationwrapper_models.exs
- Single-field validation
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
- Production Error Handling Guide - Complete guide to production-ready error handling, API responses, logging, monitoring, and recovery strategies
- Advanced Features Guide - Model validators, computed fields, enhanced runtime schemas
- Getting Started Guide - Step-by-step introduction to core concepts
- LLM Integration Guide - AI/ML use cases and optimization patterns
- 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
# 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
This project is licensed under the MIT License - see the LICENSE file for details.
- Inspired by Python's Pydantic library
- DSPy integration patterns inspired by DSPy
- Built with β€οΈ for the Elixir community
Made with Elixir π