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

18 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:

6 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