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.18.11"},
    {:ash_zoi, "~> 0.6.0"}
  ]
end

Usage

Native parsed values

Since 0.6.0, Zoi.parse/2 returns native values for supported Ash types, not their input representations. When upgrading from 0.5.x, update code that expects maps or strings for these types:

Ash type Parsed value
Union %Ash.Union{type: variant_name, value: parsed_value}
AshMoney.Types.Money %Money{} (normalized currency and Decimal amount)
:ci_string %Ash.CiString{}
Ash resource The resource's struct
Ash.TypedStruct The TypedStruct's struct
Enum An atom
Decimal, date/time The corresponding Elixir struct
Plain map, scalar A map or scalar, with typed children parsed recursively

This also applies inside arrays, NewTypes, unions, and nullable fields. Native output does not require coerce: true; that option only enables input coercion (for example, string keys or numeric strings).

Schemas still describe the input format for JSON Schema export. For example, a union accepts _union_type/_union_value maps, while returning %Ash.Union{}. Elixir typespecs describe the output values.

Resource conversion constructs a struct from the validated public attributes. It does not run Ash actions, authorization, action defaults, or resource-level validations. :only/:except filter the input schema; omitted struct fields keep their struct defaults. Unknown/unsupported types still fall back to Zoi.any(); these conversions do not replace Ash's full casting pipeline or custom callbacks.

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-input schemas that return resource structs:

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, %MyApp.User{name: "Alice", email: "alice@example.com", address: %MyApp.Address{...}, ...}}

# Only specific attributes
schema = AshZoi.to_schema(MyApp.User, only: [:name, :email])
Zoi.parse(schema, %{name: "Alice", email: "alice@example.com"})
#=> {:ok, %MyApp.User{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-input schemas with field validation and native struct output:

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, %MyApp.Profile{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, %MyApp.Profile{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, %MyApp.Ticket{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, returning %Money{}. Currency codes are validated and normalized by Money.new/3; :ex_money_opts are forwarded to that constructor:

# 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, %MyApp.Product{name: "Widget", price: %Money{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, %Ash.Union{type: :text, value: "hello"}}

Zoi.parse(schema, %{"_union_type" => "number", "_union_value" => 42})
#=> {:ok, %Ash.Union{type: :number, 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, %MyApp.Post{title: "Hello", content: %Ash.Union{type: :text, value: "some 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, %Ash.Union{type: :foo, 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 Type Zoi Schema Notes
Ash.Type.String Zoi.string() Supports min_length, max_length, match (regex)
Ash.Type.CiString Zoi.string() + transform Returns %Ash.CiString{}
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
AshMoney.Types.Money Zoi.map(%{currency, amount}) + transform Returns %Money{}; optional ash_money dep
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() + transforms Wrapper inputs become %Ash.Union{}
Ash.Type.Enum Zoi.enum() Custom enum types defined with use Ash.Type.Enum
Ash.Type.Binary Zoi.string() Closest equivalent
Ash.Type.NewType (varies) Recursively resolved to underlying subtype with constraints
Ash.TypedStruct Zoi.map() + transform Field inputs become TypedStruct structs
Ash Resources Zoi.map() + transform Public attribute inputs become resource structs
Other types Zoi.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.