Bridges Ash types to Zoi validation schemas.
AshZoi provides a simple way to convert Ash type definitions (with constraints)
into Zoi validation schemas that validate inputs and return native values.
Supported unions, money, case-insensitive strings, resources, and TypedStructs
return %Ash.Union{}, %Money{}, %Ash.CiString{}, and their respective structs.
Conversion also applies to nested values and NewType subtypes. JSON Schema still
describes the input format; Elixir typespecs describe the parsed output.
Resource parsing constructs structs, not persisted records. It does not run Ash
actions, authorization, action defaults, or resource-level validations. Unsupported
types remain Zoi.any(); this is not a replacement for Ash's full casting pipeline.
Example
# Basic type conversion
AshZoi.to_schema(:string)
#=> Zoi.string()
# With constraints
AshZoi.to_schema(:string, min_length: 3, max_length: 100)
#=> Zoi.string(min_length: 3, max_length: 100)
# Array types
AshZoi.to_schema({:array, :integer}, min_length: 1, items: [min: 0, max: 100])
#=> Zoi.array(Zoi.integer(gte: 0, lte: 100), min_length: 1)
# Map types with fields
AshZoi.to_schema(:map, fields: [
name: [type: :string, constraints: [min_length: 1]],
age: [type: :integer]
])
#=> Zoi.map(%{name: Zoi.string(min_length: 1), age: Zoi.integer()})
# Ash resources: validates a map, returns %MyApp.User{}
AshZoi.to_schema(MyApp.User)
# Ash TypedStructs: validates a map, returns %MyProfile{}
AshZoi.to_schema(MyProfile)Type Mapping
The following Ash types are mapped to their Zoi equivalents:
Ash.Type.String→Zoi.string()Ash.Type.CiString→Zoi.string()with transformation to%Ash.CiString{}Ash.Type.Integer→Zoi.integer()Ash.Type.Float→Zoi.float()Ash.Type.Boolean→Zoi.boolean()Ash.Type.Atom→Zoi.atom()orZoi.enum()(withone_ofconstraint)Ash.Type.Decimal→Zoi.decimal()AshMoney.Types.Money→ Map input transformed to%Money{}(requires optionalash_moneydependency)Ash.Type.Date→Zoi.date()Ash.Type.Time→Zoi.time()Ash.Type.DateTime→Zoi.datetime()Ash.Type.NaiveDatetime→Zoi.naive_datetime()Ash.Type.UUID→Zoi.uuid()Ash.Type.Map→Zoi.map()(with optionalfieldsconstraint)Ash.Type.Struct→Zoi.struct()(withinstance_ofandfields)Ash.Type.Module→Zoi.module()Ash.Type.Union→Zoi.discriminated_union()(wrapper input,%Ash.Union{}output)Ash.Type.Enum→Zoi.enum()(custom enum types defined withuse Ash.Type.Enum)- Ash Resources →
Zoi.map()with transformation to the resource struct Ash.Type.NewType→ Recursively resolved to underlying subtypeAsh.TypedStruct→Zoi.map()with transformation to the TypedStruct struct- Other types →
Zoi.any()
Ash Resource Support
When you pass an Ash resource module to to_schema/2, it will introspect the resource's
public attributes and generate a Zoi map-input schema returning resource structs:
defmodule MyApp.User do
use Ash.Resource
resource do
description "A registered user"
end
attributes do
attribute :name, :string, allow_nil?: false, description: "Display name"
attribute :email, :string, allow_nil?: false
attribute :age, :integer, constraints: [min: 0, max: 150]
end
end
# All public attributes
AshZoi.to_schema(MyApp.User)
# Only specific attributes
AshZoi.to_schema(MyApp.User, only: [:name, :email])
# Exclude specific attributes
AshZoi.to_schema(MyApp.User, except: [:age])TypedStruct Support
Ash TypedStructs use map-input schemas with field validation, returning their native structs after successful parsing:
defmodule MyProfile do
use Ash.TypedStruct
typed_struct do
field :username, :string, allow_nil?: false
field :age, :integer, constraints: [min: 0, max: 150]
field :bio, :string, description: "Shown on the profile page"
end
end
# Validates a map with username/age/bio fields and returns %MyProfile{}
schema = AshZoi.to_schema(MyProfile)
Zoi.parse(schema, %{username: "alice", age: 30, bio: nil})
#=> {:ok, %MyProfile{username: "alice", age: 30, bio: nil}}
Zoi.to_json_schema(schema).properties.bio.description
#=> "Shown on the profile page"NewType Support
Custom Ash.Type.NewType types are supported and recursively resolved to their
underlying subtypes with constraints merged:
defmodule SSN do
use Ash.Type.NewType, subtype_of: :string, constraints: [match: ~r/^{3}-{2}-{4}$/]
end
AshZoi.to_schema(SSN)
#=> Zoi.regex(Zoi.string(), ~r/^{3}-{2}-{4}$/)
# User-provided constraints override NewType defaults
AshZoi.to_schema(SSN, max_length: 11)
#=> Zoi.regex(Zoi.string(max_length: 11), ~r/^{3}-{2}-{4}$/)Constraint Mapping
Ash constraints are mapped to Zoi options:
- String:
min_length,max_length,match→regex - Integer/Float:
min→gte,max→lte,greater_than→gt,less_than→lt - Atom:
one_of→Zoi.enum/1 - Array:
min_length,max_length,items(element constraints) - Struct:
instance_of(struct module),fields(typed fields)
Limitations
- Array constraints
nil_items?andremove_nil_items?are not supported - Decimal constraints
precisionandscaleare ignored - DateTime constraints
precision,cast_dates_as,timezoneare ignored - Time constraint
precisionis ignored
Behavior Notes
- 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. - The
descriptionof a resource, and of each attribute or map/typed struct field, becomes the description of its schema, soZoi.to_json_schema/1andZoi.describe/1include it. On a nullable field it sits on the outeranyOf. Field descriptions that are not a non-empty string are ignored. - Constraints that don't apply to a type are silently ignored
- Map fields without a
:typedefault to:any - Unknown/unsupported Ash types fall back to
Zoi.any() - Only public resource attributes are included by default
Summary
Functions
Converts an Ash type (with optional constraints) into a Zoi validation schema.
Functions
@spec to_schema( type :: atom() | module() | {:array, any()}, constraints :: keyword() | nil ) :: struct()
Converts an Ash type (with optional constraints) into a Zoi validation schema.
Parameters
type- An Ash type atom (:string,:integer, etc.), module (Ash.Type.String), or array tuple ({:array, inner_type}).constraints- A keyword list of Ash constraints to apply. For Ash resources, you can also pass:onlyand:exceptoptions to control which attributes are included in the schema.
Options
:coerce- Whentrue, enablesZoi.coerce/1on all nodes in the generated schema. This allowsZoi.parse/2to automatically coerce values to the expected types — for example, converting JSON floats toDecimal, strings to enum atoms, and string map keys to atom keys. Useful when parsing raw JSON (e.g. from LLM responses) that needs to be cast through Ash types.
Examples
iex> schema = AshZoi.to_schema(:string)
iex> is_struct(schema)
true
iex> schema = AshZoi.to_schema(:integer, min: 0, max: 100)
iex> Zoi.parse(schema, 50)
{:ok, 50}
iex> schema = AshZoi.to_schema({:array, :string}, min_length: 1)
iex> Zoi.parse(schema, ["hello"])
{:ok, ["hello"]}