AshZoi (AshZoi v0.6.0)

Copy Markdown View Source

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 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? and remove_nil_items? are not supported
  • Decimal constraints precision and scale are ignored
  • DateTime constraints precision, cast_dates_as, timezone are ignored
  • Time constraint precision is ignored

Behavior Notes

  • 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.
  • The description of a resource, and of each attribute or map/typed struct field, becomes the description of its schema, so Zoi.to_json_schema/1 and Zoi.describe/1 include it. On a nullable field it sits on the outer anyOf. 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 :type default 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

to_schema(type, constraints \\ [])

@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 :only and :except options to control which attributes are included in the schema.

Options

  • :coerce - When true, enables Zoi.coerce/1 on all nodes in the generated schema. This allows Zoi.parse/2 to automatically coerce values to the expected types — for example, converting JSON floats to Decimal, 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"]}