Skip to content
← Blog

Zoi: A Schema Validation Library for Elixir Inspired by Zod

• 7 min read
elixiropen-sourcelibrary

I work with Elixir daily, and while it has great libraries like Ecto and Phoenix Framework, one thing kept bothering me: there wasn’t a library that felt like Zod. Elixir has alternatives for data validation, but none gave me the same ergonomics. This article walks through that gap, how Zoi came to be, and where it fits.

What is Zod?

Zod is a TypeScript-first schema declaration and validation library that allows developers to define schemas for their data and validate them at runtime. It also provides type inference, which means that the types of the data are automatically inferred from the schema. Zod also provides a way to generate JSON Schema from the defined schemas, making it easier to integrate with other tools and libraries such as OpenAPI which is widely used for API documentation.

The Birth of Zoi

Zoi (pronounced “zoy”) is an Elixir library inspired by Zod. It aims to bring the same level of ergonomics and ease of use to the Elixir ecosystem. The name “Zoi” is a playful homage to Zod, while also being a fundamentally different library designed specifically for the Elixir ecosystem.

Let’s jump into a quick comparison between Zod and Zoi to see how they relate:

// TypeScript + Zod
import * as z from "zod";

const User = z.object({
  name: z.string(),
});

// untrusted data
const input = { name: "Alice" };

// the parsed result
const data = User.parse(input);

// use the data
console.log(data.name);

Let’s see how that looks in Elixir with Zoi:

# Elixir + Zoi
schema = Zoi.object(%{
  name: Zoi.string()
})

# untrusted data
input = %{name: "Alice"}

# the parsed result
{:ok, data} = Zoi.parse(schema, input)
# use the data
dbg(data.name)

As you can see, the syntax is quite similar on the surface. Both libraries let us define schemas declaratively, parse untrusted data, and get back something defined by your schema. Zoi starts to differ once we plug it into the rest of the Elixir ecosystem and all its idioms.

The Elixir Ecosystem

Elixir contains some great libraries for data validations, the most notable being Ecto.Changeset and NimbleOptions. Both libraries are battle-tested and widely used in the community. There are also many other libraries that provide data validation such as Vex, Norm, etc. They are all great libraries, but I could not find one that provided the same level of ergonomics of Zod, and a good integration with the Elixir language and ecosystem.

While Zoi does not aim to replace any library per se, its intent is to complement existing tools in the Elixir ecosystem. Below I will explore some of the use cases where Zoi shines.

Validate External Payloads Early

One of the first places I used Zoi was with Phoenix Controllers, where I could describe the expected shape of incoming JSON payloads directly in the controller module, while generating OpenAPI documentation using the great Oaskit library.

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use Oaskit.Controller

  alias MyApp.Users

  @user_request Zoi.object(%{
    name: Zoi.string(description: "User name") |> Zoi.min(3),
    email: Zoi.email(description: "User email") |> Zoi.max(100),
    age: Zoi.integer(description: "User age", coerce: true) |> Zoi.min(18)
  }, coerce: true)

  @user_response Zoi.object(%{
    user: Zoi.object(%{
      id: Zoi.integer(),
      name: Zoi.string(),
      email: Zoi.email(),
      age: Zoi.integer()
    }, coerce: true)
  })

  operation :create,
    summary: "Create User",
    request_body: {Zoi.to_json_schema(@user_request), [required: true]},
    responses: [ok: {Zoi.to_json_schema(@user_response), []}]

  def create(conn, params) do
    with {:ok, valid_params} <- Zoi.parse(@user_request, params),
         {:ok, user} <- Users.create_user(valid_params),
         {:ok, user_response} <- Zoi.parse(@user_response, user) do
        json(conn, %{user: user_response})
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> json(%{errors: changeset.errors})

      {:error, errors} ->
        conn
        |> put_status(:bad_request)
        |> json(%{errors: Zoi.treefy_errors(errors)})
    end
  end
end

When adding metadata as description to the schema, Zoi automatically parses it into JSON Schema format, which Oaskit uses to generate OpenAPI documentation. This keeps validation and documentation in sync without a second DSL. On top of that, you get a nice UI for your docs powered by Redoc:

Zoi Controller Example

Zoi and Type Specs

One of Zod’s killer features is inferring TypeScript types from a schema. Zoi mirrors that by also inferring erlang typespecs from schemas. This means I can define a schema once, then use it for runtime validation and static analysis with Dialyzer:

defmodule Checkout.Schema do
  @schema Zoi.object(%{
    name: Zoi.string() |> Zoi.min(3)
  })

  @type t :: unquote(Zoi.type_spec(@schema))

  @spec validate(map()) :: {:ok, t()} | {:error, [Zoi.Error.t()]}
  def validate(params) do
    Zoi.parse(@schema, params)
  end
end

The generated spec will expand to:

@type t :: %{
  required(:name) => binary()
}

You can use Zoi.type_spec/1 anywhere you need a typespec, including function specs, struct definitions, or module attributes.

Working With Phoenix Forms

Zoi.Form implements Phoenix.HTML.FormData, meaning LiveView forms can use the same schema you use for APIs:

defmodule MyAppWeb.UserLive.Form do
  use Phoenix.LiveView

  @user_schema Zoi.object(%{
    name: Zoi.string() |> Zoi.min(3),
    email: Zoi.email()
  }) |> Zoi.Form.prepare()

  def mount(_params, _session, socket) do
    ctx = Zoi.Form.parse(@user_schema, %{})

    {:ok, assign(socket, form: to_form(ctx, as: :user))}
  end

  def render(assigns) do
    ~H"""
    <.form for={@form} phx-change="validate" phx-submit="save">
      <.input field={@form[:name]} label="Name" />
      <.input field={@form[:email]} label="Email" />
      <.button>Save</.button>
    </.form>
    """
  end

  def handle_event("validate", %{"user" => params}, socket) do
    ctx = Zoi.Form.parse(@user_schema, params)

    {:noreply, assign(socket, form: to_form(ctx, as: :user))}
  end

  def handle_event("save", %{"user" => params}, socket) do
    ctx = Zoi.Form.parse(@user_schema, params)

    if ctx.valid? do
      {:noreply, save_user(socket, ctx.parsed)}
    else
      {:noreply, assign(socket, form: to_form(ctx, as: :user, action: :validate))}
    end
  end
end

This is one example of how Zoi integrates with the Elixir ecosystem, providing a unified way to handle data validation across different layers of an application.

Documentation, Metadata, and JSON Schema

We saw previously how Zoi can generate OpenAPI documentation from controller schemas. This is just one example of how Zoi leverages metadata to keep documentation and validation in sync.

schema = Zoi.object(
  %{
    name: Zoi.string(description: "Customer full name", example: "John"),
    email: Zoi.email(description: "Primary contact address")
  },
  description: "Create customer payload"
)

json_schema = Zoi.to_json_schema(schema)

This will generate the following JSON Schema:

%{
  type: :object,
  description: "Create customer payload",
  required: [:name, :email],
  properties: %{
    name: %{
      type: :string,
      description: "Customer full name",
      example: "John"
    },
    email: %{
      type: :string,
      format: :email,
      pattern: "^(?!\\.)(?!.*\\.\\.)([a-z0-9_'+\\-\\.]*)[a-z0-9_+\\-]@([a-z0-9][a-z0-9\\-]*\\.)+[a-z]{2,}$"
    }
  },
  additionalProperties: false,
  "$schema": "https://json-schema.org/draft/2020-12/schema"
}

Because the metadata lives right next to the schema, exports stay accurate even after payloads evolve. The OpenAPI guide walks through wiring this into Oaskit.

JSON Schema can be used by external tools for validation, code generation, or documentation. But what about internal documentation? Can Zoi generate type documentation from schemas? Yes, it can!

defmodule MyApp.Job do
  @run_opts Zoi.keyword([
    retries: Zoi.integer(description: "Number of retries") |> Zoi.min(0) |> Zoi.max(5) |> Zoi.default(3),
    backoff: Zoi.enum([:linear, :expo], description: "Backoff configuration") |> Zoi.required()
  ])

  @type run_opts :: unquote(Zoi.type_spec(@run_opts))

  @doc """
  Run options:

  #{Zoi.describe(@run_opts)}
  """
  @spec run(run_opts()) :: :ok
  def run(opts) do
    opts = Zoi.parse!(@run_opts, opts)
    #
  end
end

This will generate the following documentation:

Run options:
  - `:retries` (`integer/0`) - Number of retries. The default value is 3.
  - `:backoff` (one of `:linear`, `:expo`) - Required. Backoff configuration.

Which is very useful when documenting functions, for libraries, or internal APIs. Because the docs live next to the schema, teammates see the contract immediately. This isn’t a complete drop-in for everything NimbleOptions does, but it covers a surprising number of internal keyword validations while keeping enforcement and docs in sync.

Should You Use Zoi?

Zoi is still a young library, but it has already proven to be useful in several projects. Check ReqLLM for instance, which uses Zoi extensively for schema object generation, used to shape the data exchanged with LLMs.

I’ve also been using Zoi in production for validating incoming API requests and generating OpenAPI documentation.

If you’re looking for a Zod-like experience in Elixir, or if you need a library that can help you with schema validation, type inference, and documentation generation, Zoi is definitely worth checking out. If you have any questions, feedback, or feature requests, please don’t hesitate to reach out on the Zoi GitHub repository.

Further Reading