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"}
  ]
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:

  • Only public attributes (:public?) are included
  • Non-public attributes are automatically excluded
  • Embedded resources used as attribute types are automatically introspected
  • The :only and :except options allow fine-grained control over included attributes
  • Ash resource attributes have allow_nil?: true by default, making them nullable in the Zoi schema. Set allow_nil?: false on your Ash attributes to make them required in the generated schema.
  • Map field definitions (:map type with :fields constraint) default allow_nil? to false, 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 to true for fields, false when 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_of type 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_value input format with string keys
  • Each variant is identified by name via Zoi.discriminated_union/3
  • Same-type variants (e.g., two :string variants) 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 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
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.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

  • min_lengthmin_length (Zoi constructor option)
  • max_lengthmax_length (Zoi constructor option)
  • match → Applied as Zoi.regex() refinement

Numeric Constraints (Integer/Float/Decimal)

  • mingte (greater than or equal to)
  • maxlte (less than or equal to)
  • greater_thangt (exclusive lower bound)
  • less_thanlt (exclusive upper bound)

Atom Constraints

  • one_ofZoi.enum(values)

Array Constraints

  • min_lengthmin_length (array-level)
  • max_lengthmax_length (array-level)
  • items → Applied to element schema (element-level constraints)

Map Constraints

  • fields → Converted to Zoi.map(fields_map) with typed fields
  • allow_nil? → Wraps field schema with Zoi.nullable()

Struct Constraints

  • instance_of → Validates struct type with Zoi.struct(module)
  • fields → Typed field schemas when combined with instance_of
  • If instance_of points 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 Zoi
    • remove_nil_items? - Not supported in Zoi
  • Decimal constraints:

    • precision - No Zoi equivalent
    • scale - No Zoi equivalent
  • DateTime constraints:

    • precision - Ignored
    • cast_dates_as - Ignored
    • timezone - Ignored
  • Time constraints:

    • precision - Ignored
  • Struct constraints:

    • When instance_of is an Ash resource, fields constraints are ignored (resource attributes are used instead)

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.