Zoi - schema validation library inspired by Zod

Zoi is a new schema validation library for Elixir.

It’s inspired by Zod from the JavaScript ecosystem, bringing a similar functional API for defining, validating, transforming and coercing data, using elixir pipes for the schema definition.

Basic Example

iex> schema = Zoi.string() |> Zoi.min(3)
iex> Zoi.parse(schema, "hello")
{:ok, "hello"}
iex> Zoi.parse(schema, "hi")
{:error, [%Zoi.Error{message: "too small: must have at least 3 characters"}]}

Transformations

iex> schema = Zoi.string() |> Zoi.trim()
iex> Zoi.parse(schema, "    world    ")
{:ok, "world"}

Coercion

iex> Zoi.string() |> Zoi.parse(123)
{:error, [%Zoi.Error{message: "invalid type: must be a string"}]}
iex> Zoi.string(coerce: true) |> Zoi.parse(123)
{:ok, "123"}

Complex schemas

iex> user_schema =
...>   Zoi.object(%{
...>     name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100),
...>     age: Zoi.integer() |> Zoi.min(18) |> Zoi.max(120),
...>     email: Zoi.email()
...>   })
iex> Zoi.parse(user_schema, %{name: "Alice", age: 30, email: "alice@email.com"})
{:ok, %{name: "Alice", age: 30, email: "alice@email.com"}}

There are many built in types, validations and transformations. Check out the documentation for all the possibilities.


:books: Docs: Zoi — Zoi v0.4.0
:package: Hex: zoi | Hex
:laptop: Repo:

I would love to hear your feedback, thoughts and any additional features you’d like to see

22 Likes

Will have to check this out. I really like Zod so this looks pretty nice. Thanks!!

3 Likes

New 0.5 version released :rocket:

Added

atom type schema

Useful for verifying atom types:

schema = Zoi.atom()
Zoi.parse(schema, :hello)
#=> {:ok, :hello}

Zoi.parse(schema, "world")
#=> {:error, [%Zoi.Error{message: "invalid type: must be an atom", path: []}]}

which can be also useful for validating maps with atom keys:

schema = Zoi.map(Zoi.atom(), Zoi.string())
Zoi.parse(schema, %{name: "John"})
#=> {:ok, %{name: "John"}}

Zoi.parse(schema, %{"name" => "John"})
#=> {:error, [%Zoi.Error{message: "invalid type: must be an atom", path: ["name"]}]}

Union and intersection custom errors

Now we can give custom errors to unions (logical OR) and intersections (logical AND)

schema = Zoi.union([Zoi.float(), Zoi.integer()], error: "something went wrong")
Zoi.parse(schema, "not a number")
#=> {:error, [%Zoi.Error{message: "something went wrong", path: []}]}

string boolean type schema

useful when parsing boolean from different sources, good for environment variables or integration with external APIs:

schema = Zoi.string_boolean()
 Zoi.parse(schema, "true")
#=> {:ok, true}

Zoi.parse(schema, "1")
#=> {:ok, true}

Zoi.parse(schema, "off")
#=> {:ok, false}

Now Zoi have all types Zod have, with some additions to fit in the elixir ecosystem :rocket:

7 Likes

The recent release is focused on bug fixes, a new Zoi.extend/2 type and some guides.

Zoi.extend/2 type:

Extends two object type schemas into one.

user = Zoi.object(%{name: Zoi.string()})
role = Zoi.object(%{role: Zoi.enum([:admin,:user])})
user_with_role = Zoi.extend(user, role)
Zoi.parse(user_with_role, %{name: "Alice", role: :admin})
#=> {:ok, %{name: "Alice", role: :admin}}

This can help you when creating composable data structures.

:books: Guides

  • Converting Keys From Object — Zoi v0.5.4 - This can be a very useful guide when you need to parse external request/responses by changing their format. For example converting camelCase to snakeCase or completely change how the schema needs to be presented in your application.
  • Validating controller parameters — Zoi v0.5.4 - A simple way to show how you can validate controller parameters using Zoi. Many applications will be fine by just using changesets directly, this is an alternative using Zoi and Changesets.
  • Generating Schemas from JSON example — Zoi v0.5.4 - This can be an interesting article for people who work integrating with many external APIs. The common approach is to parse their external data structure to an internal data structure, usually it’s done using Changesets or plain Structs and Maps. It can be a tedius job to create the data structure and parsing logic, this guide focus on automatically generating a schema from a JSON response example.

Release v0.5.4: zoi | Hex

1 Like

Would you be open to a parse!() PR?

Very convenient when you just want to blow up the world in runtime.exs on configuration errors.

2 Likes

Yes that’s a great idea

And then it’s your turn to open-source such a config library. :face_in_clouds:

New release :rocket:

This release is focused on bringing the keyword type and typespec inference from schema.

Keyword type

Defines a keyword list type schema.

schema = Zoi.keyword(name: Zoi.string(), age: Zoi.integer())
Zoi.parse(schema, [name: "Alice", age: 30])
#=> [name: "Alice", age: 30]}
Zoi.parse(schema, [name: "Alice"])
#=> {:error, [%Zoi.Error{message: "is required", path: [:age]}]}

Typespec generation

Generates the Elixir type specification for a given schema.

defmodule MyApp.Schema do
  @schema Zoi.string() |> Zoi.min(2) |> Zoi.max(100)
  @type t :: unquote(Zoi.type_spec(@schema))
end

# This will generate the following type spec:
@type t :: binary()

All types in Zoi have their own type specification, even more complex ones:

defmodule MyApp.User do
  @schema Zoi.object(%{
     name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100),
     age: Zoi.integer() |> Zoi.optional(),
     email: Zoi.email()
    })

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

Which will generate:

  @type t :: %{
     required(:name) => binary(),
     optional(:age) => integer(),
     required(:email) => binary()
   }

New parsing function

As per @olivermt recommendation, we now have the Zoi.parse!/3 function for anyone who wants to raise errors when parsing:

schema = Zoi.string() |> Zoi.min(2) |> Zoi.max(100)
Zoi.parse!(schema, "h")
# ** (Zoi.ParseError) Parsing error:
#
# too small: must have at least 2 characters

Next steps

I believe with the current API, we can also do type inference for the new elixir type system and extend some features on top of the current types.
For example, I was experimenting automatically generating structs with defaults and typespecs using Zoi API:

defmodule User do
  import Zoi.Struct, only: [structure: 1]

  structure(%{name: Zoi.string(), age: Zoi.optional(Zoi.integer()), address: Zoi.default(Zoi.string(), "Unknown")})
end

This would create the User struct as follows:

defmodule User do
  @enforce_keys [:name, :address]
  @type t :: %User{name: binary(), age: integer() | nil, address: binary()}
  defstruct name: nil, age: nil, address: "Unknown"
end

I’m not sure if this is a good feature to support but in any case I can add a guide on how to achieve this and/or add it as a feature to the library.

Release v0.5.7: zoi | Hex

3 Likes

What would be nice is to enforce either or type config

Essentially a zoi required of either one or another.

My specific usecase is required config for a library I am making that needs either an s3 config or a directory config.

could you elaborate what you mean? If you have an example because I didn’t quite understand the problem you have

New release :rocket:

Two new types added to Zoi: struct and literal types

Struct type

Now Zoi supports struct types, to validate struct and it’s fields:

defmodule MyApp.User do
  defstruct [:name, :age, :email]
end

schema = Zoi.struct(MyApp.User, %{
  name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100),
  age: Zoi.integer() |> Zoi.min(18) |> Zoi.max(120),
  email: Zoi.email()
})
Zoi.parse(schema, %MyApp.User{name: "Alice", age: 30, email: "alice@email.com"})
#=> {:ok, %MyApp.User{name: "Alice", age: 30, email: "alice@email.com"}}
Zoi.parse(schema, %{})
#=> {:error, "invalid type: must be a struct"}

by default it will try to validate the struct and it’s contents but you can also coerce the input (if it’s a map for example) to be converted to the struct:

schema = Zoi.struct(MyApp.User, %{
  name: Zoi.string(),
  age: Zoi.integer(),
  email: Zoi.email()
}, coerce: true)
Zoi.parse(schema, %{name: "Alice", age: 30, email: "alice@email.com"})
#=> {:ok, %MyApp.User{name: "Alice", age: 30, email: "alice@email.com"}}
# Also with string keys
Zoi.parse(schema, %{"name" => "Alice", "age" => 30, "email" => "alice@email.com"})
#=> {:ok, %MyApp.User{name: "Alice", age: 30, email: "alice@email.com"}}

There are also some helpers for creating structs: struct fields, defaults and the @enforce_keys helper functions:

defmodule MyApp.User do
  @schema Zoi.struct(__MODULE__, %{
    name: Zoi.string()
    age: Zoi.integer() |> Zoi.default(0) |> Zoi.optional(),
    email: Zoi.string()
  })

  @enforce_keys Zoi.Struct.enforce_keys(schema) # [:name]
  defstruct Zoi.Struct.struct_fields(schema) # [:name, :email, {:age, 0}]
end

Literal type

As the name suggest, this type represents a literal value. The input value should always match the defined literal:

schema = Zoi.literal(true)
Zoi.parse(schema, true)
{:ok, true}
Zoi.parse(schema, :other_value)
{:error, [%Zoi.Error{message: "invalid type: does not match literal"}]}
schema = Zoi.literal(42)
Zoi.parse(schema, 42)
{:ok, 42}
Zoi.parse(schema, 43)
{:error, [%Zoi.Error{message: "invalid type: does not match literal"}]}

Release v0.6.3: zoi | Hex

5 Likes

This is beautiful - can replace my hand-rolled version of this now. Thanks for contributing to the ecosystem :smiling_face_with_three_hearts:

1 Like

v0.6.6 release :rocket:

Zoi now supports a metadata option, allowing you to add additional information to your schema, which can be useful for documentation purposes.

iex> schema = Zoi.string(metadata: [id: "1", description: "A simple string"])
iex> Zoi.metadata(schema)
[id: "1", description: "A simple string"]

You can use this feature to create self-documenting schemas, and also creating examples and testing them:

defmodule MyApp.UserSchema do
  @schema Zoi.object(
            %{
              name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100),
              age: Zoi.integer() |> Zoi.optional()
            },
            metadata: [
              example: %{name: "Alice", age: 30},
              doc: "A user schema with name and optional age",
              moduledoc: "Schema representing a user with name and optional age"
            ]
          )

  @moduledoc """
  #{Zoi.metadata(@schema)[:moduledoc]}
  """

  @doc """
  #{Zoi.metadata(@schema)[:doc]}
  """
  def schema, do: @schema
end

defmodule MyApp.UserSchemaTest do
  use ExUnit.Case
  alias MyApp.UserSchema

  test "example matches schema" do
    example = Zoi.metadata(UserSchema.schema())[:example]
    assert {:ok, example} == Zoi.parse(UserSchema.schema(), example)
  end
end

New refinements added

  • Zoi.downcase/1 validate if a string is in lowercase
  • Zoi.upcase/1 validate if a string is in uppercase
  • Zoi.hex/1 validate if a string is a valid hexadecimal

Release v0.6.6: zoi | Hex

1 Like

v0.7 release with OpenAPI support :rocket:

This new version introduces the new Zoi.to_json_schema/1 function, which allows you to convert your Zoi schemas into JSON Schema format. This is useful for generating API documentation and other use cases where JSON Schema is required.

schema = Zoi.object(%{
  name: Zoi.string(metadata: [description: "The name of the person"]),
})
Zoi.to_json_schema(schema)
# %{
#   "$schema": "https://json-schema.org/draft/2020-12/schema",
#   type: :object,
#   required: [:name],
#   properties: %{name: %{type: :string, description: "The name of the person"}},
#   additionalProperties: false
# }

This feature allow us to generate OpenAPI documentation from Zoi schemas, since the 3.1 version is 100% compatible with JSON Schema.
Checkout the Zoi OpenAPI guide for more details, it shows how to use Zoi with the Oaskit library.

I also created a quickstart guide to help you get started with Zoi.

Release v0.7.1: zoi | Hex

4 Likes

Hi, @phcurado!
Is there any feature like unset that removes the field from the parsed data if the key is nil?

hey, an easy way to achieve this is with the transform API.
For example, having a schema definition like this:

defmodule MyApp.User do
  def schema() do
    Zoi.object(%{
      name: Zoi.nullable(Zoi.string()),
      age: Zoi.integer()
    })
    |> Zoi.transform(&unset_nil_fields/1)
  end

  def unset_nil_fields(map) do
    map
    |> Enum.filter(fn {_k, v} -> v != nil end)
    |> Enum.into(%{})
  end
end

It will allow nil fields to be removed from the parsed data:

iex> Zoi.parse(MyApp.User.schema(), %{name: nil, age: 30})
{:ok, %{age: 30}}
1 Like

v0.8.2 released :rocket:

Hey everyone, this release brings schema documentation introspection for Zoi.object/2 and Zoi.keyword/2 types.

Example

One common example is when passing maps or keyword lists as parameters in your function, it’s often hard to keep track of what keys are expected and their types.


@spec create_user(map(), keyword()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def create_user(params, opts \\ []) do
    ## creating user
end

and overtime you may lose track of what params or opts are being passed. Zoi can help you to give more meaning to these arguments:

@create_user_params Zoi.object(%{
  name: Zoi.string(description: "The user first name"),
  age: Zoi.integer(description: "The user age") |> Zoi.min(0) |> Zoi.optional(),
  email: Zoi.email(description: "The user email")
})

@create_user_opts Zoi.keyword([
  skip_checks: Zoi.boolean(description: "Wether to skip validation checks")
])

@doc """
Creates a new user.

Params:
#{Zoi.describe(@create_user_params)}

Options:
#{Zoi.describe(@create_user_opts)}
"""
@spec create_user(
      unquote(Zoi.type_spec(@create_user_params)),
      unquote(Zoi.type_spec(@create_user_opts))
    ) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def create_user(params, opts \\ []) do
    # parsing params and opts to ensure they conform to the schema
    params = Zoi.parse!(@create_user_params, params)
    opts = Zoi.parse!(@create_user_opts, opts)
    ## creating user
end

For library authors, this is a great way to document the expected input schemas for functions, the example above would generate the following documentation:

Creates a new user.

Params:
* `:name` (`t:String.t/0`) - Required. The user first name
* `:age` (`t:integer/0`) - The user
* `:email` (`t:String.t/0`) - Required. The user email

Opts:
* `:skip_checks` (`t:boolean/0`) - Wether to skip validation checks

The doc output was inspired by nimble_options, which offers a format compatible with HexDocs, and users will be able to check each argument and their types directly on the function documentation published in hexdocs.

More details in the oficial docs.

Release v0.8.2: zoi | Hex

3 Likes

v0.10 released with Phoenix form support :rocket:

Hi everyone! I finally released the 0.10 version of Zoi, now you can use your Zoi schemas directly in Phoenix forms.
This release implements the Phoenix.HTML.FormData protocol, which allows you to use Zoi.Context structs as form data sources.

Quick example:

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

# Parse and render (just like changesets!)
ctx = Zoi.Form.parse(@user_schema, params)
form = to_form(ctx, as: :user)

socket |> assign(:form, form)

# Use in your forms
~H"""
<.form for={@form} phx-submit="save">
  <.input field={@form[:name]} label="Name" />
  <.input field={@form[:email]} label="Email" />
  <div>
    <.button>Save</.button>
  </div>
</.form>
"""

Guides

Release v0.10: zoi | Hex

9 Likes

Thanks for making this. Looks good. A few questions:

  1. Do you have any plans for Ecto support? I think it’d be nice to create one schema and then derive any others needed from it, e.g. boundary checks etc.
  2. Do you plan to support deep partials?
  3. Wouldn’t it be more Elixir native to only have map and list in place of object and array respectively? I see you have list as an alias for array. IMO, seeing object and array feels a bit off and maybe they should be deprecated.
  4. Is there a way to globally alias Zoi as Z?
2 Likes

Hi @jam

  1. No plans for now, to be 100% compatible with ecto would be a huge effort, ecto is a different beast. Ecto also offers more specialized functions for your database operations, this would not be something I would add on Zoi. I assume you mean integrating Zoi schemas with Ecto schemas (not changesets), to have a unified definition across both, that is interesting, but not a current goal.
  2. I plan to add some helpers so you can customise nested schemas, the new Form does this already but I need to generalize the functions. Basically a function that can traverse the nested structure and apply a change to its types.
  3. In Zoi, the map type represents any key/value structure (like in Elixir), annd object defines a structured schema with specific keys. I dont think object should be deprecated, though I agree the naming feels less native to Elixir.
  4. No, you can only alias like that inside a module (alias Zoi, as: Z)
1 Like