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"},
    {:ash_zoi, "~> 0.5.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 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
AshMoney.Types.Money Zoi.map(%{currency, amount}) Optional ash_money dep; min/max apply to amount
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.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() 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

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.