Zoi: A Zod-Inspired Schema Validation Library for Elixir

Elixir Development

Discover Zoi, an Elixir schema validation library inspired by Zod. Learn how it enhances data validation, generates type specifications, and integrates seamlessly with Phoenix, offering a powerful solution for robust Elixir applications.

As an Elixir developer, I frequently work with robust libraries like Ecto and the Phoenix Framework. However, I consistently felt a gap concerning a schema validation library that offered the same ergonomic elegance as Zod in the TypeScript ecosystem. While Elixir has existing data validation alternatives, none provided that familiar, streamlined experience. This article delves into that specific need, chronicles the inception of Zoi, and illustrates its versatile place within the Elixir landscape.

What is Zod?

Zod is a TypeScript-first library designed for schema declaration and validation. It empowers developers to define schemas for their data and validate them at runtime. A key feature of Zod is its type inference capability, which automatically deduces data types directly from the schema. Furthermore, Zod can generate JSON Schema from these definitions, facilitating seamless integration with tools like OpenAPI, widely used for API documentation.

The Birth of Zoi

Zoi (pronounced “zoy”) is an Elixir library that takes direct inspiration from Zod. Its core mission is to bring a comparable level of ergonomics and ease of use to the Elixir ecosystem. The name “Zoi” serves as a playful homage to Zod, while fundamentally representing a distinct library meticulously crafted for the unique characteristics of Elixir.

Let’s quickly compare Zod and Zoi to highlight their conceptual similarities:

// 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);

Now, let's see how this translates into Elixir using 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 evident, the surface-level syntax is quite similar. Both libraries enable declarative schema definition, parsing of untrusted data, and the return of structured data conforming to your schema. Zoi begins to distinguish itself as it integrates with the broader Elixir ecosystem and its inherent idioms.

The Elixir Ecosystem

Elixir boasts several excellent libraries for data validation, most notably Ecto.Changeset and NimbleOptions. Both are proven and widely adopted within the community. Numerous other validation libraries exist, such as Vex and Norm. While these are all commendable, I found myself searching for a library that matched Zod's ergonomics and offered seamless integration with the Elixir language and ecosystem.

Zoi is not intended as a replacement for any existing library; rather, its purpose is to complement the powerful tools already present in the Elixir ecosystem. Below, I’ll explore some compelling use cases where Zoi truly excels.

Validate External Payloads Early

One of the primary applications for Zoi has been within Phoenix Controllers. It allows for describing the expected shape of incoming JSON payloads directly within the controller module. This approach also facilitates the generation of OpenAPI documentation using the impressive 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

By adding metadata like description to the schema, Zoi automatically parses it into JSON Schema format, which Oaskit then leverages to generate comprehensive OpenAPI documentation. This approach ensures that validation rules and documentation remain perfectly synchronized without requiring a separate Domain Specific Language (DSL). Additionally, you benefit from a polished UI for your documentation, powered by Redoc:

Zoi and Type Specs

One of Zod’s most compelling features is its ability to infer TypeScript types directly from a schema. Zoi mirrors this by inferring Erlang typespecs from its schemas. This means you can define a schema once and then utilize it for both 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 typespec will expand to:

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

You can seamlessly integrate Zoi.type_spec/1 wherever a typespec is required, including function specifications, struct definitions, or module attributes.

Working With Phoenix Forms

Zoi.Form implements the Phoenix.HTML.FormData protocol, enabling LiveView forms to utilize the exact same schema definitions that you employ for APIs, ensuring consistency across your application layers:

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 example beautifully illustrates how Zoi integrates into the Elixir ecosystem, offering a unified and consistent approach to data validation across various application components.

Documentation, Metadata, and JSON Schema

As demonstrated, Zoi can generate OpenAPI documentation directly from controller schemas. This exemplifies how Zoi leverages metadata to maintain synchronization between documentation and validation rules.

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

By keeping metadata alongside the schema definition, exports remain accurate even as payload structures evolve. The OpenAPI guide provides further details on integrating this with Oaskit.

JSON Schema is highly beneficial for external tools, facilitating validation, code generation, or documentation. But what about internal documentation? Zoi can indeed generate type documentation from schemas!

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.

Such generated documentation is incredibly valuable for documenting functions, libraries, or internal APIs. Since the documentation lives adjacent to the schema, teammates immediately understand the contract. While not a complete drop-in replacement for everything NimbleOptions offers, it covers a surprisingly wide range of internal keyword validations, keeping both enforcement and documentation harmonized.

Should You Use Zoi?

Zoi is a relatively new library, but it has already proven its utility in several projects. For instance, ReqLLM extensively uses Zoi for schema object generation, particularly for shaping data exchanged with Large Language Models (LLMs).

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

If you're seeking a Zod-like development experience in Elixir, or if you require a library that streamlines schema validation, type inference, and documentation generation, Zoi is certainly worth exploring. For any questions, feedback, or feature requests, please engage with the community on the Zoi GitHub repository.

Further Reading

  • Quickstart Guide
  • Recipes
  • Main API Reference
  • Using Zoi to generate OpenAPI specs
  • Validating controller parameters
  • Converting Keys From Object
  • Generating Schemas from JSON

← Back to Blog