A library that bridges Ash types to 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:
def deps do
[
{:ash, "~> 3.0"},
{:zoi, "~> 0.17.3"},
{:ash_zoi, "~> 0.1.0"}
]
endUsage
Basic Type Conversion
Convert Ash type atoms to Zoi schemas:
# 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:
# 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:
# 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:
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:
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
:onlyand:exceptoptions allow fine-grained control over included attributes - Ash resource attributes have
allow_nil?: trueby default, making them nullable in the Zoi schema. Setallow_nil?: falseon your Ash attributes to make them required in the generated schema. - Map field definitions (
:maptype with:fieldsconstraint) defaultallow_nil?tofalse, matching Ash's map field defaults.
Ash TypedStructs
Convert Ash TypedStructs to map schemas with field validation:
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 totruefor fields,falsewhen 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:
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_oftype 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):
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:
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:
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_valueinput format with string keys - Each variant is identified by name via
Zoi.discriminated_union/3 - Same-type variants (e.g., two
:stringvariants) 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:
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 asZoi.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 toZoi.map(fields_map)with typed fieldsallow_nil?→ Wraps field schema withZoi.nullable()
Struct Constraints
instance_of→ Validates struct type withZoi.struct(module)fields→ Typed field schemas when combined withinstance_of- If
instance_ofpoints 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 Zoiremove_nil_items?- Not supported in Zoi
Decimal constraints:
precision- No Zoi equivalentscale- No Zoi equivalent
DateTime constraints:
precision- Ignoredcast_dates_as- Ignoredtimezone- Ignored
Time constraints:
precision- Ignored
Struct constraints:
- When
instance_ofis an Ash resource,fieldsconstraints are ignored (resource attributes are used instead)
- When
Custom Ash types not listed in the type mapping table will fall back to Zoi.any(), which accepts any value.
Examples
Validate User Input
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
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
endValidate Ash Resource Data
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
endDocumentation
Documentation is available on HexDocs.
License
MIT License - see LICENSE file for details.