# AshZoi
A library that bridges [Ash](https://hexdocs.pm/ash) types to [Zoi](https://hexdocs.pm/zoi) validation schemas.
`AshZoi` provides a simple way to convert Ash type definitions (with constraints) into Zoi validation schemas that can be used for runtime validation.
## Installation
Add `ash_zoi` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ash, "~> 3.0"},
{:zoi, "~> 0.17.3"},
{:ash_zoi, "~> 0.1.0"}
]
end
```
## Usage
### Basic Type Conversion
Convert Ash type atoms to Zoi schemas:
```elixir
# Simple types
AshZoi.to_schema(:string)
#=> Zoi.string()
AshZoi.to_schema(:integer)
#=> Zoi.integer()
AshZoi.to_schema(:boolean)
#=> Zoi.boolean()
```
### With Constraints
Apply Ash constraints that are automatically mapped to Zoi validations:
```elixir
# String constraints
schema = AshZoi.to_schema(:string, min_length: 3, max_length: 100)
Zoi.parse(schema, "hello")
#=> {:ok, "hello"}
Zoi.parse(schema, "hi")
#=> {:error, [%Zoi.Error{code: :greater_than_or_equal_to, ...}]}
# Regex matching
schema = AshZoi.to_schema(:string, match: ~r/^[a-z]+$/)
Zoi.parse(schema, "hello")
#=> {:ok, "hello"}
# Integer constraints
schema = AshZoi.to_schema(:integer, min: 0, max: 100)
Zoi.parse(schema, 50)
#=> {:ok, 50}
# Float constraints
schema = AshZoi.to_schema(:float, greater_than: 0.0, less_than: 1.0)
Zoi.parse(schema, 0.5)
#=> {:ok, 0.5}
# Atom enum
schema = AshZoi.to_schema(:atom, one_of: [:red, :green, :blue])
Zoi.parse(schema, :red)
#=> {:ok, :red}
```
### Array Types
Convert array types with element-level and array-level constraints:
```elixir
# Array of strings
schema = AshZoi.to_schema({:array, :string})
Zoi.parse(schema, ["hello", "world"])
#=> {:ok, ["hello", "world"]}
# Array with element constraints
schema = AshZoi.to_schema({:array, :integer}, items: [min: 0, max: 100])
Zoi.parse(schema, [0, 50, 100])
#=> {:ok, [0, 50, 100]}
# Array with length constraints
schema = AshZoi.to_schema({:array, :string}, min_length: 1, max_length: 5)
Zoi.parse(schema, ["hello"])
#=> {:ok, ["hello"]}
# Combined constraints
schema = AshZoi.to_schema(
{:array, :integer},
min_length: 1,
max_length: 10,
items: [min: 0, max: 100]
)
```
### Map Types with Fields
Convert map types with typed fields:
```elixir
schema = AshZoi.to_schema(:map,
fields: [
name: [type: :string, constraints: [min_length: 2, max_length: 50]],
age: [type: :integer, constraints: [min: 0, max: 150]],
email: [type: :string, constraints: [match: ~r/@/]]
]
)
Zoi.parse(schema, %{name: "Alice", age: 30, email: "alice@example.com"})
#=> {:ok, %{name: "Alice", age: 30, email: "alice@example.com"}}
# Nullable fields
schema = AshZoi.to_schema(:map,
fields: [
name: [type: :string],
middle_name: [type: :string, allow_nil?: true]
]
)
Zoi.parse(schema, %{name: "Alice", middle_name: nil})
#=> {:ok, %{name: "Alice", middle_name: nil}}
```
### Ash Resources
Convert Ash resources to map schemas based on their attributes:
```elixir
defmodule MyApp.Address do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :street, :string, public?: true, allow_nil?: false
attribute :city, :string, public?: true, allow_nil?: false
attribute :zip, :string, public?: true, constraints: [max_length: 10]
end
end
defmodule MyApp.User do
use Ash.Resource
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true, allow_nil?: false, constraints: [min_length: 1]
attribute :email, :string, public?: true, allow_nil?: false
attribute :age, :integer, public?: true, constraints: [min: 0, max: 150]
attribute :address, MyApp.Address, public?: true # Embedded resource
attribute :internal_field, :string # private by default
end
end
# Convert entire resource (only public attributes)
schema = AshZoi.to_schema(MyApp.User)
Zoi.parse(schema, %{
name: "Alice",
email: "alice@example.com",
age: 30,
address: %{street: "123 Main", city: "Springfield", zip: "12345"}
})
#=> {:ok, %{name: "Alice", email: "alice@example.com", ...}}
# Only specific attributes
schema = AshZoi.to_schema(MyApp.User, only: [:name, :email])
Zoi.parse(schema, %{name: "Alice", email: "alice@example.com"})
#=> {:ok, %{name: "Alice", email: "alice@example.com"}}
# Exclude specific attributes
schema = AshZoi.to_schema(MyApp.User, except: [:age])
```
**Notes:**
- Only public attributes (`:public?`) are included
- Non-public attributes are automatically excluded
- Embedded resources used as attribute types are automatically introspected
- The `:only` and `:except` options allow fine-grained control over included attributes
- Ash resource attributes have `allow_nil?: true` by default, making them nullable in the Zoi schema.
Set `allow_nil?: false` on your Ash attributes to make them required in the generated schema.
- Map field definitions (`:map` type with `:fields` constraint) default `allow_nil?` to `false`,
matching Ash's map field defaults.
### Ash TypedStructs
Convert Ash TypedStructs to map schemas with field validation:
```elixir
defmodule MyApp.Profile do
use Ash.TypedStruct
typed_struct do
field :username, :string, allow_nil?: false
field :age, :integer, constraints: [min: 0, max: 150]
field :bio, :string
field :website, :string, constraints: [match: ~r/^https?:\/\//]
end
end
# Convert TypedStruct to schema
schema = AshZoi.to_schema(MyApp.Profile)
Zoi.parse(schema, %{username: "alice", age: 25, bio: "Hello", website: "https://example.com"})
#=> {:ok, %{username: "alice", age: 25, bio: "Hello", website: "https://example.com"}}
# Field constraints are enforced
Zoi.parse(schema, %{username: "alice", age: -1, bio: "Hello", website: "https://example.com"})
#=> {:error, [%Zoi.Error{code: :greater_than_or_equal_to, path: [:age], ...}]}
# allow_nil?: false is enforced
Zoi.parse(schema, %{username: nil, age: 25, bio: "Hello", website: "https://example.com"})
#=> {:error, [%Zoi.Error{code: :invalid_type, path: [:username], ...}]}
# Nullable fields accept nil (default: allow_nil?: true)
Zoi.parse(schema, %{username: "alice", age: 25, bio: nil, website: "https://example.com"})
#=> {:ok, %{username: "alice", age: 25, bio: nil, website: "https://example.com"}}
```
**Notes:**
- TypedStructs are automatically detected and converted to map schemas
- Field types and constraints are preserved from the TypedStruct definition
- `allow_nil?` is respected (defaults to `true` for fields, `false` when explicitly set)
- All Ash type features (constraints, validations) work with TypedStruct fields
### Ash NewTypes
Convert custom `Ash.Type.NewType` types with their baked-in constraints:
```elixir
defmodule MyApp.SSN do
use Ash.Type.NewType,
subtype_of: :string,
constraints: [match: ~r/^\d{3}-\d{2}-\d{4}$/]
end
defmodule MyApp.PositiveInteger do
use Ash.Type.NewType,
subtype_of: :integer,
constraints: [min: 0]
end
# NewTypes are automatically resolved to their underlying type with constraints
schema = AshZoi.to_schema(MyApp.SSN)
Zoi.parse(schema, "123-45-6789")
#=> {:ok, "123-45-6789"}
Zoi.parse(schema, "invalid-ssn")
#=> {:error, [%Zoi.Error{code: :invalid_format, ...}]}
# User-provided constraints override NewType defaults
schema = AshZoi.to_schema(MyApp.PositiveInteger, max: 100)
Zoi.parse(schema, 50)
#=> {:ok, 50}
Zoi.parse(schema, 150) # Exceeds user-provided max
#=> {:error, [%Zoi.Error{code: :less_than_or_equal_to, ...}]}
Zoi.parse(schema, -1) # Violates NewType's min: 0 constraint
#=> {:error, [%Zoi.Error{code: :greater_than_or_equal_to, ...}]}
```
**Notes:**
- NewTypes are automatically detected using `Ash.Type.NewType.new_type?/1`
- The underlying `subtype_of` type is resolved recursively
- NewType constraints are merged with user-provided constraints
- User-provided constraints take precedence (override NewType defaults)
- Supports all Ash types as subtypes (primitives, composites, other NewTypes)
### Union Types
Ash union types are typically defined as NewTypes wrapping `:union`
(see [Ash.Type.Union NewType integration](https://hexdocs.pm/ash/Ash.Type.Union.html#module-newtype-integration)):
```elixir
defmodule MyApp.Content do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
types: [
text: [type: :string, constraints: [max_length: 1000]],
number: [type: :integer, constraints: [min: 0]]
]
]
end
# The NewType is automatically unwrapped and the union variants are converted
# to a Zoi discriminated union using Ash's _union_type/_union_value format
schema = AshZoi.to_schema(MyApp.Content)
Zoi.parse(schema, %{"_union_type" => "text", "_union_value" => "hello"})
#=> {:ok, %{"_union_type" => "text", "_union_value" => "hello"}}
Zoi.parse(schema, %{"_union_type" => "number", "_union_value" => 42})
#=> {:ok, %{"_union_type" => "number", "_union_value" => 42}}
# Unknown variant name
Zoi.parse(schema, %{"_union_type" => "unknown", "_union_value" => "hello"})
#=> {:error, [...]}
# Wrong type for variant
Zoi.parse(schema, %{"_union_type" => "number", "_union_value" => "not a number"})
#=> {:error, [...]}
```
Union NewTypes work seamlessly as resource attribute types:
```elixir
defmodule MyApp.Post do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :title, :string, public?: true, allow_nil?: false
attribute :content, MyApp.Content, public?: true, allow_nil?: false
end
end
schema = AshZoi.to_schema(MyApp.Post)
Zoi.parse(schema, %{title: "Hello", content: %{"_union_type" => "text", "_union_value" => "some text"}})
#=> {:ok, %{title: "Hello", content: %{"_union_type" => "text", ...}}}
```
You can also pass union types directly:
```elixir
schema = AshZoi.to_schema(:union, types: [
foo: [type: :string],
bar: [type: :string]
])
# Same-type variants are distinguished by name
Zoi.parse(schema, %{"_union_type" => "foo", "_union_value" => "hello"})
#=> {:ok, %{"_union_type" => "foo", "_union_value" => "hello"}}
```
**Notes:**
- Unions use Ash's `_union_type`/`_union_value` input format with string keys
- Each variant is identified by name via `Zoi.discriminated_union/3`
- Same-type variants (e.g., two `:string` variants) are properly distinguished
- Per-variant constraints are enforced
- NewType unions are automatically unwrapped and resolved
### Module Name Resolution
You can use either Ash type atoms or module names:
```elixir
AshZoi.to_schema(:string)
# Same as:
AshZoi.to_schema(Ash.Type.String)
```
## Type Mapping
The following Ash types are mapped to their Zoi equivalents:
| Ash Type | Zoi Schema | Notes |
|----------|------------|-------|
| `Ash.Type.String` | `Zoi.string()` | Supports `min_length`, `max_length`, `match` (regex) |
| `Ash.Type.CiString` | `Zoi.string()` | Case-insensitive string, validated as string |
| `Ash.Type.Integer` | `Zoi.integer()` | Supports `min`, `max`, `greater_than`, `less_than` |
| `Ash.Type.Float` | `Zoi.float()` | Supports `min`, `max`, `greater_than`, `less_than` |
| `Ash.Type.Boolean` | `Zoi.boolean()` | |
| `Ash.Type.Atom` | `Zoi.atom()` or `Zoi.enum()` | With `one_of` constraint → `Zoi.enum()` |
| `Ash.Type.Decimal` | `Zoi.decimal()` | Supports `min`, `max`, `greater_than`, `less_than` |
| `Ash.Type.Date` | `Zoi.date()` | |
| `Ash.Type.Time` | `Zoi.time()` | `TimeUsec` also maps to `time()` |
| `Ash.Type.DateTime` | `Zoi.datetime()` | All datetime variants map to `datetime()` |
| `Ash.Type.NaiveDatetime` | `Zoi.naive_datetime()` | |
| `Ash.Type.UUID` | `Zoi.uuid()` | `UUIDv7` also maps to `uuid()` |
| `Ash.Type.Map` | `Zoi.map()` | With `fields` constraint → `Zoi.map(fields_map)` |
| `Ash.Type.Struct` | `Zoi.struct()` | With `instance_of` and optional `fields` constraints |
| `Ash.Type.Module` | `Zoi.module()` | |
| `Ash.Type.Union` | `Zoi.discriminated_union()` | Uses `_union_type`/`_union_value` format, distinguishes same-type variants |
| `Ash.Type.Binary` | `Zoi.string()` | Closest equivalent |
| `Ash.Type.NewType` | (varies) | Recursively resolved to underlying subtype with constraints |
| `Ash.TypedStruct` | `Zoi.map()` | Introspected from typed struct fields (treated as map) |
| Ash Resources | `Zoi.map()` | Introspected from resource public attributes |
| Other types | `Zoi.any()` | Fallback for unknown/custom types |
## Constraint Mapping
Ash constraints are automatically mapped to Zoi validations:
### String Constraints
- `min_length` → `min_length` (Zoi constructor option)
- `max_length` → `max_length` (Zoi constructor option)
- `match` → Applied as `Zoi.regex()` refinement
### Numeric Constraints (Integer/Float/Decimal)
- `min` → `gte` (greater than or equal to)
- `max` → `lte` (less than or equal to)
- `greater_than` → `gt` (exclusive lower bound)
- `less_than` → `lt` (exclusive upper bound)
### Atom Constraints
- `one_of` → `Zoi.enum(values)`
### Array Constraints
- `min_length` → `min_length` (array-level)
- `max_length` → `max_length` (array-level)
- `items` → Applied to element schema (element-level constraints)
### Map Constraints
- `fields` → Converted to `Zoi.map(fields_map)` with typed fields
- `allow_nil?` → Wraps field schema with `Zoi.nullable()`
### Struct Constraints
- `instance_of` → Validates struct type with `Zoi.struct(module)`
- `fields` → Typed field schemas when combined with `instance_of`
- If `instance_of` points to an Ash resource, the resource's attributes are introspected
## Limitations
The following Ash constraints are not supported or ignored:
- **Array constraints:**
- `nil_items?` - Not supported in Zoi
- `remove_nil_items?` - Not supported in Zoi
- **Decimal constraints:**
- `precision` - No Zoi equivalent
- `scale` - No Zoi equivalent
- **DateTime constraints:**
- `precision` - Ignored
- `cast_dates_as` - Ignored
- `timezone` - Ignored
- **Time constraints:**
- `precision` - Ignored
- **Struct constraints:**
- When `instance_of` is an Ash resource, `fields` constraints are ignored (resource attributes are used instead)
Custom Ash types not listed in the type mapping table will fall back to `Zoi.any()`, which accepts any value.
## Examples
### Validate User Input
```elixir
defmodule MyApp.UserSchema do
def user_schema do
AshZoi.to_schema(:map,
fields: [
username: [
type: :string,
constraints: [min_length: 3, max_length: 20, match: ~r/^[a-zA-Z0-9_]+$/]
],
email: [
type: :string,
constraints: [match: ~r/@/]
],
age: [
type: :integer,
constraints: [min: 13, max: 120]
],
bio: [
type: :string,
constraints: [max_length: 500],
allow_nil?: true
],
tags: [
type: {:array, :string},
constraints: [max_length: 5, items: [max_length: 20]]
]
]
)
end
def validate_user(data) do
user_schema() |> Zoi.parse(data)
end
end
# Usage
MyApp.UserSchema.validate_user(%{
username: "john_doe",
email: "john@example.com",
age: 25,
bio: nil,
tags: ["elixir", "phoenix"]
})
#=> {:ok, %{username: "john_doe", email: "john@example.com", ...}}
```
### Validate API Parameters
```elixir
defmodule MyApp.API.Params do
def pagination_schema do
AshZoi.to_schema(:map,
fields: [
page: [type: :integer, constraints: [min: 1]],
per_page: [type: :integer, constraints: [min: 1, max: 100]],
sort_by: [type: :atom, constraints: [one_of: [:name, :date, :popularity]]],
order: [type: :atom, constraints: [one_of: [:asc, :desc]]]
]
)
end
end
# In your controller:
def index(conn, params) do
case MyApp.API.Params.pagination_schema() |> Zoi.parse(params) do
{:ok, validated_params} ->
# Use validated_params
json(conn, %{data: fetch_data(validated_params)})
{:error, errors} ->
conn
|> put_status(400)
|> json(%{errors: format_errors(errors)})
end
end
```
### Validate Ash Resource Data
```elixir
defmodule MyApp.BlogPost do
use Ash.Resource
attributes do
uuid_primary_key :id
attribute :title, :string, public?: true, allow_nil?: false, constraints: [min_length: 5, max_length: 200]
attribute :body, :string, public?: true, allow_nil?: false, constraints: [min_length: 10]
attribute :published, :boolean, public?: true, default: false
attribute :tags, {:array, :string}, public?: true, constraints: [max_length: 10]
attribute :author_email, :string, public?: true, constraints: [match: ~r/@/]
end
end
# Validate input data before creating a resource
def create_post(input_data) do
schema = AshZoi.to_schema(MyApp.BlogPost, except: [:id])
case Zoi.parse(schema, input_data) do
{:ok, validated_data} ->
MyApp.BlogPost
|> Ash.Changeset.for_create(:create, validated_data)
|> MyApp.Api.create()
{:error, errors} ->
{:error, format_validation_errors(errors)}
end
end
```
## Documentation
Documentation is available on [HexDocs](https://hexdocs.pm/ash_zoi).
## License
MIT License - see [LICENSE](LICENSE) file for details.