AshZoi

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.3.0"}
]
end

Usage

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:

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:

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, ...}]}

Ash Enums

Custom enum types defined with use Ash.Type.Enum are automatically detected and converted to Zoi.enum(values):

defmodule MyApp.TicketStatus do
use Ash.Type.Enum, values: [:open, :closed, :pending]
end
schema = AshZoi.to_schema(MyApp.TicketStatus)
Zoi.parse(schema, :open)
#=> {:ok, :open}
Zoi.parse(schema, :invalid)
#=> {:error, [%Zoi.Error{code: :invalid_enum_value, ...}]}

Enums also work as resource attribute types:

defmodule MyApp.Ticket do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :title, :string, public?: true, allow_nil?: false
attribute :status, MyApp.TicketStatus, public?: true, allow_nil?: false
end
end
schema = AshZoi.to_schema(MyApp.Ticket)
Zoi.parse(schema, %{title: "Bug", status: :open})
#=> {:ok, %{title: "Bug", status: :open}}
Zoi.parse(schema, %{title: "Bug", status: :invalid})
#=> {:error, [...]}

AshMoney Support

If you use ash_money, add it to your dependencies and AshMoney.Types.Money fields will be converted to a map schema with currency (string) and amount (decimal) fields:

# In mix.exs:
{:ash_money, "~> 0.2"}
# Money attributes in resources
defmodule MyApp.Product do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :name, :string, public?: true, allow_nil?: false
attribute :price, AshMoney.Types.Money, public?: true, allow_nil?: false
end
end
schema = AshZoi.to_schema(MyApp.Product)
Zoi.parse(schema, %{name: "Widget", price: %{currency: "USD", amount: Decimal.new("9.99")}})
#=> {:ok, %{name: "Widget", price: %{currency: "USD", amount: #Decimal<9.99>}}}
# min/max constraints apply to the amount
schema = AshZoi.to_schema(AshMoney.Types.Money, min: 0, max: 1000)

Without ash_money in your dependencies, money types fall back to Zoi.any().

Notes:

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:

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 TypeZoi SchemaNotes
Ash.Type.StringZoi.string()Supports min_length, max_length, match (regex)
Ash.Type.CiStringZoi.string()Case-insensitive string, validated as string
Ash.Type.IntegerZoi.integer()Supports min, max, greater_than, less_than
Ash.Type.FloatZoi.float()Supports min, max, greater_than, less_than
Ash.Type.BooleanZoi.boolean()
Ash.Type.AtomZoi.atom() or Zoi.enum()With one_of constraint → Zoi.enum()
Ash.Type.DecimalZoi.decimal()Supports min, max, greater_than, less_than
AshMoney.Types.MoneyZoi.map(%{currency, amount})Optional ash_money dep; min/max apply to amount
Ash.Type.DateZoi.date()
Ash.Type.TimeZoi.time()TimeUsec also maps to time()
Ash.Type.DateTimeZoi.datetime()All datetime variants map to datetime()
Ash.Type.NaiveDatetimeZoi.naive_datetime()
Ash.Type.UUIDZoi.uuid()UUIDv7 also maps to uuid()
Ash.Type.MapZoi.map()With fields constraint → Zoi.map(fields_map)
Ash.Type.StructZoi.struct()With instance_of and optional fields constraints
Ash.Type.ModuleZoi.module()
Ash.Type.UnionZoi.discriminated_union()Uses _union_type/_union_value format, distinguishes same-type variants
Ash.Type.EnumZoi.enum()Custom enum types defined with use Ash.Type.Enum
Ash.Type.BinaryZoi.string()Closest equivalent
Ash.Type.NewType(varies)Recursively resolved to underlying subtype with constraints
Ash.TypedStructZoi.map()Introspected from typed struct fields (treated as map)
Ash ResourcesZoi.map()Introspected from resource public attributes
Other typesZoi.any()Fallback for unknown/custom types

Constraint Mapping

Ash constraints are automatically mapped to Zoi validations:

String Constraints

Numeric Constraints (Integer/Float/Decimal)

Atom Constraints

Array Constraints

Map Constraints

Struct Constraints

Limitations

The following Ash constraints are not supported or ignored:

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
end

Validate 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
end

Documentation

Documentation is available on HexDocs.

License

MIT License - see LICENSE file for details.