Current section
Files
Jump to
Current section
Files
lib/zodish.ex
defmodule Zodish do
@moduledoc ~S"""
Zodish is a schema parser and validation library heavily inspired by
JavaScript's Zod.
"""
import Zodish.Helpers, only: [i: 1]
alias Zodish.Type.Any, as: TAny
alias Zodish.Type.Atom, as: TAtom
alias Zodish.Type.Boolean, as: TBoolean
alias Zodish.Type.Date, as: TDate
alias Zodish.Type.DateTime, as: TDateTime
alias Zodish.Type.Decimal, as: TDecimal
alias Zodish.Type.Email, as: TEmail
alias Zodish.Type.Enum, as: TEnum
alias Zodish.Type.Float, as: TFloat
alias Zodish.Type.Integer, as: TInteger
alias Zodish.Type.List, as: TList
alias Zodish.Type.Literal, as: TLiteral
alias Zodish.Type.Map, as: TMap
alias Zodish.Type.Number, as: TNumber
alias Zodish.Type.Numeric, as: TNumeric
alias Zodish.Type.Optional, as: TOptional
alias Zodish.Type.Record, as: TRecord
alias Zodish.Type.Refine, as: Refine
alias Zodish.Type.String, as: TString
alias Zodish.Type.Struct, as: TStruct
alias Zodish.Type.Transform, as: Transform
alias Zodish.Type.Tuple, as: TTuple
alias Zodish.Type.Union, as: TUnion
alias Zodish.Type.URI, as: TUri
alias Zodish.Type.Uuid, as: TUuid
@on_unsupported_coercion Application.compile_env(:zodish, :on_unsupported_coercion, :warn)
@typedoc ~S"""
Any Zodish type that has a shape (i.e. `Zodish.Type.Map` and
`Zodish.Type.Struct`).
"""
@type shaped() :: %{
required(:__struct__) => module(),
required(:shape) => %{required(atom()) => Zodish.Type.t()},
optional(atom()) => any()
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# PROTOCOL API #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
@doc ~S"""
Parses a value based on the given type.
iex> Z.string()
iex> |> Z.parse("Hello, World!")
{:ok, "Hello, World!"}
"""
@spec parse(type :: Zodish.Type.t(), value :: any()) ::
{:ok, any()}
| {:error, Zodish.Issue.t()}
defdelegate parse(type, value), to: Zodish.Type
@doc ~S"""
Same as `parse/1` but raises an error if failed to parse the given
params.
iex> Z.string()
iex> |> Z.parse!("Hello, World!")
"Hello, World!"
iex> Z.string()
iex> |> Z.parse!(123)
** (Zodish.Issue) expected a string, got integer
"""
@spec parse!(type, value) :: term()
when type: Zodish.Type.t(),
value: term()
def parse!(type, value) do
case parse(type, value) do
{:ok, parsed} -> parsed
{:error, issue} -> raise(issue)
end
end
@doc ~S"""
Infers the type spec of a given Zodish type to be used with `@type`.
Z.spec(type)
## Examples
Unquote the returned value of `Z.spec/1` into your `@type` definition,
Zodish will pick the most readable way to represent your types:
@schema Z.map(%{
name: Z.string(),
email: Z.email()
})
@type t() :: unquote(Z.spec(@schema))
### Type `any`
iex> Z.any() |> Z.to_spec()
quote(do: any())
### Type `atom`
iex> Z.atom() |> Z.to_spec()
quote(do: atom())
### Type `boolean`
iex> Z.boolean() |> Z.to_spec()
quote(do: boolean())
### Type `date`
iex> Z.date() |> Z.to_spec()
quote(do: Date.t())
### Type `datetime`
iex> Z.datetime() |> Z.to_spec()
quote(do: DateTime.t())
### Type `decimal`
iex> Z.decimal() |> Z.to_spec()
quote(do: Decimal.t())
### Type `email`
iex> Z.email() |> Z.to_spec()
quote(do: String.t())
### Type `enum`
iex> Z.enum([:foo, :bar, :baz]) |> Z.to_spec()
quote(do: :foo | :bar | :baz)
### Type `float`
iex> Z.float() |> Z.to_spec()
quote(do: float())
### Type `integer`
iex> Z.integer() |> Z.to_spec()
quote(do: integer())
iex> Z.integer(gt: 0) |> Z.to_spec()
quote(do: pos_integer())
iex> Z.integer(gte: 0) |> Z.to_spec()
quote(do: non_neg_integer())
iex> Z.integer(gt: 0, lt: 10) |> Z.to_spec()
{:.., [], [1, 9]}
iex> Z.integer(gte: 0, lte: 10) |> Z.to_spec()
{:.., [], [0, 10]}
### Type `list`
iex> Z.list(Z.integer()) |> Z.to_spec()
quote(do: [integer()])
iex> Z.list(Z.integer()) |> Z.min(1) |> Z.to_spec()
quote(do: [integer(), ...])
### Type `literal`
iex> Z.literal(:foo) |> Z.to_spec()
:foo
iex> Z.literal(true) |> Z.to_spec()
true
iex> Z.literal(false) |> Z.to_spec()
false
iex> Z.literal(~D[2025-10-31]) |> Z.to_spec()
quote(do: Date.t())
iex> Z.literal(~U[2025-10-31T00:00:00.000Z]) |> Z.to_spec()
quote(do: DateTime.t())
iex> Z.literal(Decimal.new(0)) |> Z.to_spec()
quote(do: Decimal.t())
iex> Z.literal(3.14) |> Z.to_spec()
quote(do: float())
iex> Z.literal(-1) |> Z.to_spec()
quote(do: integer())
iex> Z.literal(0) |> Z.to_spec()
quote(do: non_neg_integer())
iex> Z.literal(123) |> Z.to_spec()
quote(do: pos_integer())
iex> Z.literal([foo: 1, bar: 2]) |> Z.to_spec()
quote(do: keyword())
iex> Z.literal([1, 2, 3]) |> Z.to_spec()
quote(do: list())
iex> Z.literal(%{foo: :bar}) |> Z.to_spec()
quote(do: map())
iex> Z.literal("foo") |> Z.to_spec()
quote(do: String.t())
iex> Z.literal(%Address{}) |> Z.to_spec()
{:%, [], [{:__aliases__, [alias: false], [ZodishTest.Address]}, {:%{}, [], []}]}
iex> Z.literal(%{__struct__: ZodishTest.Address}) |> Z.to_spec()
{:%, [], [{:__aliases__, [alias: false], [ZodishTest.Address]}, {:%{}, [], []}]}
iex> Z.literal({:ok, :foo, :bar}) |> Z.to_spec()
quote(do: {:ok, :foo, :bar})
iex> Z.literal("http://localhost:3000/") |> Z.to_spec()
quote(do: String.t())
iex> Z.literal("4c5995ef-1bd6-43a7-86a6-e721a3e2d99a") |> Z.to_spec()
quote(do: String.t())
### Type `map`
iex> Z.map(%{
iex> foo: Z.string(),
iex> bar: Z.integer()
iex> })
iex> |> Z.to_spec()
quote(do: %{foo: String.t(), bar: integer()})
iex> Z.map(%{
iex> foo: Z.string(),
iex> bar: Z.optional(Z.integer())
iex> })
iex> |> Z.to_spec()
quote(do: %{foo: String.t(), bar: integer() | nil})
iex> Z.map(%{
iex> foo: Z.string(),
iex> bar: Z.optional(Z.integer(), default: 10)
iex> })
iex> |> Z.to_spec()
quote(do: %{foo: String.t(), bar: integer()})
### Type `number`
iex> Z.number() |> Z.to_spec()
quote(do: number())
### Type `numeric`
iex> Z.numeric() |> Z.to_spec()
quote(do: String.t())
### Type `optional`
iex> Z.string() |> Z.optional() |> Z.to_spec()
quote(do: String.t() | nil)
iex> Z.string() |> Z.optional(default: "foo") |> Z.to_spec()
quote(do: String.t())
### Type `record`
iex> Z.record(keys: Z.string(), values: Z.integer()) |> Z.to_spec()
{:%{}, [], [{{:optional, [], [quote(do: String.t())]}, quote(do: integer())}]}
### Type `refine`
iex> Z.integer()
iex> |> Z.refine(&(&1 > 0), error: "must be positive")
iex> |> Z.to_spec()
quote(do: integer())
iex> Z.string()
iex> |> Z.refine(&String.starts_with?(&1, "A"), error: "must start with A")
iex> |> Z.to_spec()
quote(do: String.t())
### Type `string`
iex> Z.string() |> Z.to_spec()
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
### Type `struct`
iex> Z.struct(Address, %{
iex> line_1: Z.string(min: 1, max: 100),
iex> line_2: Z.optional(Z.string(min: 1, max: 100)),
iex> })
iex> |> Z.to_spec()
{:%, [], [{:__aliases__, [alias: false], [ZodishTest.Address]}, {:%{}, [], [line_1: quote(do: String.t()), line_2: quote(do: String.t() | nil)]}]}
### Type `tuple`
iex> Z.tuple([Z.literal(:error), Z.string(min: 1)])
iex> |> Z.to_spec()
{:{}, [], [:error, {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}]}
### Type `union`
iex> Z.union([Z.literal(:ok), Z.literal(:error)])
iex> |> Z.to_spec()
{:|, [], [:ok, :error]}
### Type `uri`
iex> Z.uri() |> Z.to_spec()
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
### Type `uuid`
iex> Z.uuid() |> Z.to_spec()
{:<<>>, [], [{:"::", [], [{:_, [], Elixir}, 288]}]}
"""
@spec to_spec(type) :: Macro.t()
when type: Zodish.Type.t()
defdelegate to_spec(type), to: Zodish.Type
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# TYPES API #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
@doc ~S"""
Defines a type that accepts any value.
iex> Z.any()
iex> |> Z.parse("string")
{:ok, "string"}
iex> Z.any()
iex> |> Z.parse(123)
{:ok, 123}
iex> Z.any()
iex> |> Z.parse(%{key: "value"})
{:ok, %{key: "value"}}
iex> Z.any()
iex> |> Z.parse({:foo, :bar})
{:ok, {:foo, :bar}}
"""
@spec any() :: TAny.t()
defdelegate any(), to: TAny, as: :new
@doc ~S"""
Defines an atom type.
iex> Z.atom()
iex> |> Z.parse(:foo)
{:ok, :foo}
## Options
You can use `:coerce` to cast the given string into an atom.
iex> Z.atom(coerce: true)
iex> |> Z.parse("foo")
{:ok, :foo}
By default, `:coerce` will only cast strings for existing atoms
since BEAM has a limited number of atom.
iex> Z.atom(coerce: true)
iex> |> Z.parse("alksdhfwejh")
{:error, %Zodish.Issue{message: "cannot coerce string \"alksdhfwejh\" into an existing atom"}}
If you want to allow unsafe coercion of any string into an atom, you
can set `:coerce` to `:unsafe`.
iex> Z.atom(coerce: :unsafe)
iex> |> Z.parse("lskdjfalsdjf")
{:ok, :lskdjfalsdjf}
"""
@spec atom(opts :: [option]) :: TAtom.t()
when option: {:coerce, boolean() | :unsafe}
defdelegate atom(opts \\ []), to: TAtom, as: :new
@doc ~S"""
Defines a boolean type.
iex> Z.boolean()
iex> |> Z.parse(true)
{:ok, true}
## Options
You can use `:coerce` to cast the given value into a boolean.
iex> Z.boolean(coerce: true)
iex> |> Z.parse("true")
{:ok, true}
The accepted boolean-like values are:
| value | coerced to |
| :----------- | :--------- |
| `"true"` | `true` |
| `"1"` | `true` |
| `1` | `true` |
| `"yes"` | `true` |
| `"y"` | `true` |
| `"on"` | `true` |
| `"enabled"` | `true` |
| `"false"` | `false` |
| `"0"` | `false` |
| `0` | `false` |
| `"no"` | `false` |
| `"n"` | `false` |
| `"off"` | `false` |
| `"disabled"` | `false` |
"""
@spec boolean(opts :: [option]) :: TBoolean.t()
when option: {:coerce, boolean()}
defdelegate boolean(opts \\ []), to: TBoolean, as: :new
@doc ~S"""
Enables coercion for the given type.
iex> Z.integer()
iex> |> Z.coerce()
iex> |> Z.parse("123")
{:ok, 123}
If the given type doesn't support coercion but it encapsulates an
inner type, then the coercion will be applied to the inner type.
iex> Z.optional(Z.integer())
%Zodish.Type.Optional{
inner_type: %Zodish.Type.Integer{coerce: false}
}
iex> Z.optional(Z.integer())
iex> |> Z.coerce()
%Zodish.Type.Optional{
inner_type: %Zodish.Type.Integer{coerce: true}
}
iex> Z.optional(Z.integer())
iex> |> Z.coerce()
iex> |> Z.parse("123")
{:ok, 123}
"""
@spec coerce(type, value :: boolean() | :unsafe) :: type
when type: TAtom.t()
@spec coerce(type, value :: boolean()) :: type
when type: struct()
def coerce(type, value \\ true)
def coerce(%mod{coerce: _} = type, value), do: apply(mod, :coerce, [type, value])
def coerce(%_{inner_type: inner_type} = type, value), do: %{type | inner_type: coerce(inner_type, value)}
case @on_unsupported_coercion do
:raise -> def coerce(%mod{}, _), do: raise(ArgumentError, "The type #{i(mod)} doesn't support coercion")
:warn -> def coerce(%mod{} = type, _), do: Zodish.Helpers.warn(type, "The type #{i(mod)} doesn't support coercion")
:ignore -> def coerce(%_{} = type, _), do: type
value -> raise(ArgumentError, "Invalid option #{i(value)} for config :on_unsupported_coercion, expected :raise, :warn or :ignore")
end
@doc ~S"""
Defines a date type.
iex> Z.date()
iex> |> Z.parse(~D[2025-06-27])
{:ok, ~D[2025-06-27]}
## Options
You can use `:coerce` to cast the given value into a Date.
iex> Z.date(coerce: true)
iex> |> Z.parse("2025-06-27")
{:ok, ~D[2025-06-27]}
"""
@spec date(opts :: [option]) :: TDate.t()
when option: {:coerce, boolean()}
defdelegate date(opts \\ []), to: TDate, as: :new
@doc ~S"""
Defines a date-time type.
iex> Z.datetime()
iex> |> Z.parse(~U[2025-06-27T12:00:00.000Z])
{:ok, ~U[2025-06-27T12:00:00.000Z]}
## Options
You can use `:coerce` to cast the given value into a DateTime.
iex> Z.datetime(coerce: true)
iex> |> Z.parse("2025-06-27T12:00:00.000Z")
{:ok, ~U[2025-06-27T12:00:00.000Z]}
You can use `:after` to ensure the given date-time is after a
certain timestamp.
iex> Z.datetime(after: ~U[2030-01-01T00:00:00.000Z])
iex> |> Z.parse(~U[2031-06-27T12:00:00.000Z])
{:ok, ~U[2031-06-27T12:00:00.000Z]}
iex> Z.datetime(after: ~U[2030-01-01T00:00:00.000Z])
iex> |> Z.parse(~U[2025-06-27T12:00:00.000Z])
{:error, %Zodish.Issue{message: "must be after 2030-01-01 00:00:00.000Z"}}
Alternatively you can provide an MFA tuple or a function that
returns a `DateTime`:
iex> Z.datetime(after: {DateTime, :utc_now, []})
iex> |> Z.parse(~U[2030-01-01T00:00:00.000Z])
{:ok, ~U[2030-01-01T00:00:00.000Z]}
iex> Z.datetime(after: (fn -> DateTime.utc_now() end))
iex> |> Z.parse(~U[2030-01-01T00:00:00.000Z])
{:ok, ~U[2030-01-01T00:00:00.000Z]}
You can also provide a relative time:
iex> {:ok, _} =
iex> Z.datetime(after: {15, :minute, :from_now})
iex> |> Z.parse(DateTime.add(DateTime.utc_now(), 16, :minute))
iex> {:error, _} =
iex> Z.datetime(after: {15, :minute, :from_now})
iex> |> Z.parse(DateTime.add(DateTime.utc_now(), 14, :minute))
Likewise, you can use `:before` to ensure the given date-time is
before the given timestamp.
iex> {:ok, _} =
iex> Z.datetime(before: {15, :minute, :from_now})
iex> |> Z.parse(DateTime.add(DateTime.utc_now(), 14, :minute))
iex> {:error, _} =
iex> Z.datetime(before: {15, :minute, :from_now})
iex> |> Z.parse(DateTime.add(DateTime.utc_now(), 16, :minute))
"""
@spec datetime(opts :: [option]) :: TDateTime.t()
when option:
{:coerce, boolean()}
| {:after, DateTime.t()}
| {:after, mfa()}
| {:after, (-> DateTime.t())}
| {:after, {n ::integer(), unit :: :millisecond | :second | :minute | :hour | :day | :week | :month | :year, :from_now}}
| {:before, DateTime.t()}
| {:before, mfa()}
| {:before, (-> DateTime.t())}
| {:before, {n ::integer(), unit :: :millisecond | :second | :minute | :hour | :day | :week | :month | :year, :from_now}}
defdelegate datetime(opts \\ []), to: TDateTime, as: :new
@doc ~S"""
Defines a decimal type.
iex> Z.decimal()
iex> |> Z.parse(Decimal.from_float(3.14))
{:ok, Decimal.new("3.14")}
## Options
You can use `:gt`, `:gte`, `:lt` and `:lte` to constrain the allowed
values.
iex> Z.decimal(gt: Decimal.new("0.0"))
iex> |> Z.parse(Decimal.new("0.0"))
{:error, %Zodish.Issue{message: "expected a decimal greater than 0.0, got 0.0"}}
iex> Z.decimal(gte: Decimal.new("1.0"))
iex> |> Z.parse(Decimal.new("0.5"))
{:error, %Zodish.Issue{message: "expected a decimal greater than or equal to 1.0, got 0.5"}}
iex> Z.decimal(lt: Decimal.new("1.0"))
iex> |> Z.parse(Decimal.new("1.1"))
{:error, %Zodish.Issue{message: "expected a decimal less than 1.0, got 1.1"}}
iex> Z.decimal(lte: Decimal.new("1.0"))
iex> |> Z.parse(Decimal.new("1.1"))
{:error, %Zodish.Issue{message: "expected a decimal less than or equal to 1.0, got 1.1"}}
You can use `:coerce` to cast the given value into a decimal before
validation.
iex> Z.decimal(coerce: true)
iex> |> Z.parse("3.14")
{:ok, Decimal.new("3.14")}
iex> Z.decimal(coerce: true)
iex> |> Z.parse("123")
{:ok, Decimal.new("123")}
"""
@spec decimal(opts :: [option]) :: TDateTime.t()
when option:
{:coerce, boolean()}
| {:gt, Decimal.t()}
| {:gt, Zodish.Option.t(Decimal.t())}
| {:gte, Decimal.t()}
| {:gte, Zodish.Option.t(Decimal.t())}
| {:lt, Decimal.t()}
| {:lt, Zodish.Option.t(Decimal.t())}
| {:lte, Decimal.t()}
| {:lte, Zodish.Option.t(Decimal.t())}
defdelegate decimal(opts \\ []), to: TDecimal, as: :new
@doc ~S"""
Defines an email type (decorated String type).
iex> Z.email()
iex> |> Z.parse("foo@bar.com")
{:ok, "foo@bar.com"}
## Options
You can choose which ruleset to use for validating the email address
by setting the `:ruleset` option. There are 4 options available:
- `:gmail` (default) - same rules as Gmail;
- `:html5` - same rules browsers use to validate `input[type=email]` fields;
- `:rfc5322` - same rules as the classic emailregex.com (RFC 5322); and
- `:unicode` - a loose set of rules that allows Unicode (good for intl emails);
iex> Z.email(ruleset: :gmail)
iex> |> Z.parse("foo@bar.com")
{:ok, "foo@bar.com"}
iex> Z.email(ruleset: :html5)
iex> |> Z.parse("foo@bar.com")
{:ok, "foo@bar.com"}
iex> Z.email(ruleset: :rfc5322)
iex> |> Z.parse("foo@bar.com")
{:ok, "foo@bar.com"}
iex> Z.email(ruleset: :unicode)
iex> |> Z.parse("foo@bar.com")
{:ok, "foo@bar.com"}
## Errors
When the given string is empty:
iex> Z.email()
iex> |> Z.parse("")
{:error, %Zodish.Issue{message: "cannot be empty"}}
When the given string is not a valid email address:
iex> Z.email()
iex> |> Z.parse("foo@")
{:error, %Zodish.Issue{message: "invalid email address"}}
"""
@spec email(opts :: [option]) :: TEmail.t()
when option: {:ruleset, :gmail | :html5 | :rfc5322 | :unicode}
defdelegate email(opts \\ []), to: TEmail, as: :new
@doc ~S"""
Defines an enum type (atoms only).
iex> Z.enum([:foo, :bar])
iex> |> Z.parse(:foo)
{:ok, :foo}
## Options
You can use `:coerce` to cast the given value into an atom.
iex> Z.enum(coerce: true, values: [:foo, :bar])
iex> |> Z.parse("bar")
{:ok, :bar}
## Errors
When the given value is not an atom:
iex> Z.enum([:foo, :bar])
iex> |> Z.parse("baz")
{:error, %Zodish.Issue{message: "expected an atom, got string"}}
When the given value is not an atom but coerce is set to true:
iex> Z.enum(coerce: true, values: [:foo, :bar])
iex> |> Z.parse("baz")
{:error, %Zodish.Issue{message: "is invalid"}}
"""
@spec enum(values :: [atom(), ...]) :: TEnum.t()
@spec enum(opts :: [option]) :: TEnum.t()
when option: {:coerce, boolean()} | {:values, [atom(), ...]}
defdelegate enum(opts), to: TEnum, as: :new
@doc ~S"""
Updates the given type's `:length` option.
iex> Z.integer()
iex> |> Z.list(length: 1)
iex> |> Z.length(2)
iex> |> Z.parse([1])
{:error, %Zodish.Issue{message: "expected list to have exactly 2 items, got 1 item"}}
iex> Z.string(length: 5)
iex> |> Z.length(1)
iex> |> Z.parse("Hello")
{:error, %Zodish.Issue{message: "expected string to have exactly 1 character, got 5 characters"}}
"""
@spec length(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TList.t()
when type: TList.t()
@spec length(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TString.t()
when type: TString.t()
def length(type, length, opts \\ [])
def length(%TList{} = type, length, opts), do: TList.length(type, length, opts)
def length(%TString{} = type, length, opts), do: TString.length(type, length, opts)
@doc ~S"""
Defines a float type.
iex> Z.float()
iex> |> Z.parse(3.14)
{:ok, 3.14}
## Options
You can use `:gt`, `:gte`, `:lt` and `:lte` to constrain the allowed
values.
iex> Z.float(gt: 0.0)
iex> |> Z.parse(0.0)
{:error, %Zodish.Issue{message: "expected a float greater than 0.0, got 0.0"}}
iex> Z.float(gte: 1.0)
iex> |> Z.parse(0.5)
{:error, %Zodish.Issue{message: "expected a float greater than or equal to 1.0, got 0.5"}}
iex> Z.float(lt: 1.0)
iex> |> Z.parse(1.1)
{:error, %Zodish.Issue{message: "expected a float less than 1.0, got 1.1"}}
iex> Z.float(lte: 1.0)
iex> |> Z.parse(1.1)
{:error, %Zodish.Issue{message: "expected a float less than or equal to 1.0, got 1.1"}}
You can use `:coerce` to cast the given value into a float before
validation.
iex> Z.float(coerce: true)
iex> |> Z.parse("3.14")
{:ok, 3.14}
iex> Z.float(coerce: true)
iex> |> Z.parse("123")
{:ok, 123.0}
"""
@spec float(opts :: [option]) :: TFloat.t()
when option:
{:coerce, boolean()}
| {:gt, float()}
| {:gt, Zodish.Option.t(float())}
| {:gte, float()}
| {:gte, Zodish.Option.t(float())}
| {:lt, float()}
| {:lt, Zodish.Option.t(float())}
| {:lte, float()}
| {:lte, Zodish.Option.t(float())}
defdelegate float(opts \\ []), to: TFloat, as: :new
@doc ~S"""
Defines a integer type.
iex> Z.integer()
iex> |> Z.parse(3)
{:ok, 3}
## Options
You can use `:gt`, `:gte`, `:lt` and `:lte` to constrain the allowed
values.
iex> Z.integer(gt: 0)
iex> |> Z.parse(0)
{:error, %Zodish.Issue{message: "expected an integer greater than 0, got 0"}}
iex> Z.integer(gte: 0)
iex> |> Z.parse(-1)
{:error, %Zodish.Issue{message: "expected an integer greater than or equal to 0, got -1"}}
iex> Z.integer(lt: 1)
iex> |> Z.parse(2)
{:error, %Zodish.Issue{message: "expected an integer less than 1, got 2"}}
iex> Z.integer(lte: 1)
iex> |> Z.parse(2)
{:error, %Zodish.Issue{message: "expected an integer less than or equal to 1, got 2"}}
You can use `:coerce` to cast the given value into a integer before
validation.
iex> Z.integer(coerce: true)
iex> |> Z.parse("123")
{:ok, 123}
iex> Z.integer(coerce: true)
iex> |> Z.parse("123.678")
{:ok, 123}
If a float is provided, it will be truncated to an integer.
iex> Z.integer(coerce: true)
iex> |> Z.parse(123.678)
{:ok, 123}
"""
@spec integer(opts :: [option]) :: TInteger.t()
when option:
{:coerce, boolean()}
| {:gt, integer()}
| {:gt, Zodish.Option.t(integer())}
| {:gte, integer()}
| {:gte, Zodish.Option.t(integer())}
| {:lt, integer()}
| {:lt, Zodish.Option.t(integer())}
| {:lte, integer()}
| {:lte, Zodish.Option.t(integer())}
defdelegate integer(opts \\ []), to: TInteger, as: :new
@doc ~S"""
Defines a list type.
iex> Z.list(Z.integer())
iex> |> Z.parse([1, 2, 3])
{:ok, [1, 2, 3]}
iex> Z.list(Z.integer())
iex> |> Z.parse([1, 2, "3"])
{:error, %Zodish.Issue{
message: "one or more items of the list did not match the expected type",
parse_score: 3,
issues: [%Zodish.Issue{path: ["2"], message: "expected an integer, got string"}]
}}
## Options
You can use `:length`, `:min` and `:max` to
constrain the length of the list.
iex> Z.list(Z.integer(), length: 3)
iex> |> Z.parse([1, 2, 3, 4])
{:error, %Zodish.Issue{message: "expected list to have exactly 3 items, got 4 items"}}
iex> Z.list(Z.integer(), min: 1)
iex> |> Z.parse([])
{:error, %Zodish.Issue{message: "expected list to have at least 1 item, got 0 items"}}
iex> Z.list(Z.integer(), max: 3)
iex> |> Z.parse([1, 2, 3, 4])
{:error, %Zodish.Issue{message: "expected list to have at most 3 items, got 4 items"}}
"""
@spec list(inner_type :: Zodish.Type.t(), opts :: [option]) :: TList.t()
when option:
{:length, non_neg_integer()}
| {:length, Zodish.Option.t(non_neg_integer())}
| {:min, non_neg_integer()}
| {:min, Zodish.Option.t(non_neg_integer())}
| {:max, non_neg_integer()}
| {:max, Zodish.Option.t(non_neg_integer())}
defdelegate list(inner_type, opts \\ []), to: TList, as: :new
@doc ~S"""
Defines a type that only accepts a specific value.
iex> Z.literal("foo")
iex> |> Z.parse("foo")
{:ok, "foo"}
iex> Z.literal(42)
iex> |> Z.parse(51)
{:error, %Zodish.Issue{message: "expected to be exactly 42, got 51"}}
"""
@spec literal(value :: any()) :: TLiteral.t()
defdelegate literal(value), to: TLiteral, as: :new
@doc ~S"""
Defines a map type.
iex> Z.map(%{name: Z.string(), age: Z.integer(gte: 18)})
iex> |> Z.parse(%{name: "John Doe", age: 27})
{:ok, %{name: "John Doe", age: 27}}
The keys of the parsed map will always be atoms.
iex> Z.map(%{name: Z.string(), age: Z.integer(gte: 18)})
iex> |> Z.parse(%{"name" => "John Doe", "age" => 27})
{:ok, %{name: "John Doe", age: 27}}
## Options
You can specify one of two behaviors for how to handle unknown fields
in the input value:
- `:strip` (default) - Unknown fields will be ignored and not included
in the parsed result;
- `:strict` - Unknown fields will cause a validation error.
iex> Z.map(:strip, %{name: Z.string(), age: Z.integer(gte: 18)})
iex> |> Z.parse(%{name: "John Doe", email: "johndoe@gmail.com", age: 27})
{:ok, %{name: "John Doe", age: 27}}
iex> Z.map(:strict, %{name: Z.string(), age: Z.integer(gte: 18)})
iex> |> Z.parse(%{name: "John Doe", email: "johndoe@gmail.com", age: 27})
{:error, %Zodish.Issue{
message: "one or more fields failed validation",
parse_score: 3,
issues: [%Zodish.Issue{path: ["email"], message: "unknown field"}]
}}
You can also use :coerce to cast values from struct or keyword lists
to maps before validation:
iex> Z.coerce(Z.map(:strip, %{name: Z.string(), age: Z.integer(gte: 18)}))
iex> |> Z.parse(name: "John Doe", email: "johndoe@gmail.com", age: 27)
{:ok, %{name: "John Doe", age: 27}}
If you need to validate a map where you don't know what keys will be
present, then use `Z.record/1` instead.
"""
@spec map(mode, shape) :: TMap.t()
when mode: :strip | :strict,
shape: %{required(atom()) => Zodish.Type.t()}
@spec map([option, ...], shape) :: TMap.t()
when option: {:coerce, boolean()} | {:mode, :strip | :strict},
shape: %{required(atom()) => Zodish.Type.t()}
defdelegate map(mode_or_opts \\ :strip, shape), to: TMap, as: :new
@doc ~S"""
Updates the given type's `:max` option.
iex> Z.integer()
iex> |> Z.list(max: 3)
iex> |> Z.max(2)
iex> |> Z.parse([1, 2, 3])
{:error, %Zodish.Issue{message: "expected list to have at most 2 items, got 3 items"}}
iex> Z.string(max: 3)
iex> |> Z.max(1)
iex> |> Z.parse("Foo")
{:error, %Zodish.Issue{message: "expected string to have at most 1 character, got 3 characters"}}
"""
@spec max(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TList.t()
when type: TList.t()
@spec max(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TString.t()
when type: TString.t()
def max(type, length, opts \\ [])
def max(%TList{} = type, length, opts), do: TList.max(type, length, opts)
def max(%TString{} = type, length, opts), do: TString.max(type, length, opts)
@doc ~S"""
Merges two Map types into one, where `:mode` is inherited from the
most strict mode between the two given types.
iex> a = Z.map(:strip, %{name: Z.string()})
iex> b = Z.map(:strict, %{age: Z.integer()})
iex>
iex> Z.merge(a, b)
iex> |> Z.parse(%{name: "John Doe", age: 27, email: "johndoe@gmail.com"})
{:error, %Zodish.Issue{
message: "one or more fields failed validation",
parse_score: 3,
issues: [%Zodish.Issue{path: ["email"], message: "unknown field"}]
}}
iex> a = Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string()
iex> })
iex>
iex> b = Z.struct(Address, %{
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex>
iex> Z.merge(a, b)
iex> |> Z.parse(%{
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
line_1: "123 Main St",
line_2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701"
}}
"""
@spec merge(a :: TMap.t(), b :: TMap.t()) :: TMap.t()
@spec merge(a :: TStruct.t(), b :: TStruct.t()) :: TStruct.t()
def merge(%TMap{} = a, %TMap{} = b), do: TMap.new(most_strict(a.mode, b.mode), Map.merge(a.shape, b.shape))
def merge(%TStruct{module: same} = a, %TStruct{module: same} = b) do
[]
|> Keyword.put(:coerce, a.coerce or b.coerce)
|> Keyword.put(:mode, most_strict(a.mode, b.mode))
|> Keyword.put(:module, same)
|> Keyword.put(:shape, Map.merge(a.shape, b.shape))
|> TStruct.new()
end
@doc ~S"""
Updates the given type's `:min` option.
iex> Z.integer()
iex> |> Z.list(min: 1)
iex> |> Z.min(2)
iex> |> Z.parse([1])
{:error, %Zodish.Issue{message: "expected list to have at least 2 items, got 1 item"}}
iex> Z.string(min: 1)
iex> |> Z.min(6)
iex> |> Z.parse("Foo")
{:error, %Zodish.Issue{message: "expected string to have at least 6 characters, got 3 characters"}}
"""
@spec min(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TList.t()
when type: TList.t()
@spec min(type, length :: non_neg_integer(), opts :: [{:error, String.t()}]) :: TString.t()
when type: TString.t()
def min(type, length, opts \\ [])
def min(%TList{} = type, length, opts), do: TList.min(type, length, opts)
def min(%TString{} = type, length, opts), do: TString.min(type, length, opts)
@doc ~S"""
Defines a number type.
iex> Z.number()
iex> |> Z.parse(3)
{:ok, 3}
iex> Z.number()
iex> |> Z.parse(3.14)
{:ok, 3.14}
## Options
You can use `:gt`, `:gte`, `:lt` and `:lte` to constrain the allowed
values.
iex> Z.number(gt: 0)
iex> |> Z.parse(0)
{:error, %Zodish.Issue{message: "expected a number greater than 0, got 0"}}
iex> Z.number(gte: 0)
iex> |> Z.parse(-1)
{:error, %Zodish.Issue{message: "expected a number greater than or equal to 0, got -1"}}
iex> Z.number(lt: 1)
iex> |> Z.parse(2)
{:error, %Zodish.Issue{message: "expected a number less than 1, got 2"}}
iex> Z.number(lte: 1)
iex> |> Z.parse(2)
{:error, %Zodish.Issue{message: "expected a number less than or equal to 1, got 2"}}
You can use `:coerce` to cast the given value into a number before
validation.
iex> Z.number(coerce: true)
iex> |> Z.parse("123")
{:ok, 123}
iex> Z.number(coerce: true)
iex> |> Z.parse("123.678")
{:ok, 123.678}
"""
@spec number(opts :: [option]) :: TInteger.t()
when option:
{:coerce, boolean()}
| {:gt, number()}
| {:gt, Zodish.Option.t(number())}
| {:gte, number()}
| {:gte, Zodish.Option.t(number())}
| {:lt, number()}
| {:lt, Zodish.Option.t(number())}
| {:lte, number()}
| {:lte, Zodish.Option.t(number())}
defdelegate number(opts \\ []), to: TNumber, as: :new
@doc ~S"""
Defines a numeric string type.
iex> Z.numeric()
iex> |> Z.parse("123456")
{:ok, "123456"}
iex> Z.numeric()
iex> |> Z.parse("a1b2c3")
{:error, %Zodish.Issue{message: "must contain 0-9 digits only"}}
You can constrain the number of digits by using the `:length`,
`:min` and `:max` options:
iex> Z.numeric(length: 3)
iex> |> Z.parse("1234")
{:error, %Zodish.Issue{message: "expected numeric string to have exactly 3 digits, got 4 digits"}}
iex> Z.numeric(min: 2)
iex> |> Z.parse("1")
{:error, %Zodish.Issue{message: "expected numeric string to have at least 2 digits, got 1 digit"}}
iex> Z.numeric(max: 3)
iex> |> Z.parse("1234")
{:error, %Zodish.Issue{message: "expected numeric string to have at most 3 digits, got 4 digits"}}
"""
@spec numeric(opts :: [option]) :: TString.t()
when option:
{:length, non_neg_integer()}
| {:length, Zodish.Option.t(non_neg_integer())}
| {:min, non_neg_integer()}
| {:min, Zodish.Option.t(non_neg_integer())}
| {:max, non_neg_integer()}
| {:max, Zodish.Option.t(non_neg_integer())}
defdelegate numeric(opts \\ []), to: TNumeric, as: :new
@doc ~S"""
Removes the specified keys from the type's shape.
iex> Z.map(%{name: Z.string(), age: Z.integer()})
iex> |> Z.omit([:age])
iex> |> Z.parse(%{name: "John Doe"})
{:ok, %{name: "John Doe"}}
iex> Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex> |> Z.omit([:line_1, :line_2])
iex> |> Z.parse(%{
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
city: "Springfield",
state: "IL",
zip: "62701"
}}
"""
@spec omit(type, keys :: [atom()]) :: TMap.t()
when type: TMap.t()
@spec omit(type, keys :: [atom()]) :: TStruct.t()
when type: TStruct.t()
def omit(%TMap{} = type, keys) when is_list(keys), do: %{type | shape: Map.drop(type.shape, keys)}
def omit(%TStruct{} = type, keys) when is_list(keys), do: %{type | shape: Map.drop(type.shape, keys)}
@doc ~S"""
Makes a given inner type optional, where you can also define a
default value to be used when the actual value resolves to `nil`.
iex> Z.integer()
iex> |> Z.parse(nil)
{:error, %Zodish.Issue{message: "is required"}}
iex> Z.optional(Z.integer())
iex> |> Z.parse(nil)
{:ok, nil}
## Options
You can use `:default` to define a default value to be used when
the actual value resolves to `nil`.
iex> Z.optional(Z.integer(), default: 42)
iex> |> Z.parse(nil)
{:ok, 42}
iex> Z.optional(Z.integer(), default: fn -> 42 end)
iex> |> Z.parse(nil)
{:ok, 42}
The default value must satisfy the inner type of the `Zodish.Type.Optional`.
If you provide a default value other than a function that doesn't
satisfy the inner type, it will raise an `ArgumentError`.
iex> Z.optional(Z.integer(), default: "not a number")
** (ArgumentError) The default value must satisfy the inner type of Zodish.Type.Optional
If you provide a function as the default value though and it returns
a value that doesn't satisfy the inner type, it will return a
`Zodish.Issue` since this check cannot be done at compile time.
iex> Z.optional(Z.integer(), default: fn -> "abc" end)
iex> |> Z.parse(nil)
{:error, %Zodish.Issue{message: "expected an integer, got string"}}
If you're defining your schema at compile time into a compiled
variable, then you won't be able to use an anonymous function as the
default value. So instead you can pass an mfa tuple of the function
that should be called to get the default value:
iex> Z.optional(Z.integer(), default: {Echo, :say, [42]})
iex> |> Z.parse(nil)
{:ok, 42}
If you call `optional/1` on an already optional type, it will just
return the same type back:
iex> Z.string()
iex> |> Z.optional()
iex> |> Z.optional()
%Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: nil}
iex> Z.string()
iex> |> Z.optional(default: "foo")
iex> |> Z.optional()
%Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: "foo"}
If you call `optional/2` though (with a default value), it will
override the existing default value:
iex> Z.string()
iex> |> Z.optional()
iex> |> Z.optional(default: "foo")
%Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: "foo"}
"""
@spec optional(inner_type :: Zodish.Type.t(), opts :: [option]) :: TOptional.t()
when option: {:default, (-> any()) | any() | nil}
def optional(inner_type, opts \\ [])
def optional(%TOptional{} = type, [default: _] = opts), do: TOptional.new(type.inner_type, opts)
def optional(%TOptional{} = type, []), do: type
def optional(%_{} = inner_type, opts), do: TOptional.new(inner_type, opts)
@doc ~S"""
Checks whether the given type is an optional type.
iex> Z.string()
iex> |> Z.optional()
iex> |> Z.optional?()
true
iex> Z.string()
iex> |> Z.optional?()
false
It also works if the type is wrapped in refinements and/or
transformations:
iex> Z.string()
iex> |> Z.optional()
iex> |> Z.refine(fn _ -> true end)
iex> |> Z.refine(fn _ -> true end)
iex> |> Z.transform(fn value -> value end)
iex> |> Z.optional?()
true
"""
@spec optional?(type :: Zodish.Type.t()) :: boolean()
def optional?(%TOptional{}), do: true
def optional?(%_{inner_type: %_{} = inner_type}), do: optional?(inner_type)
def optional?(_), do: false
@doc ~S"""
Makes all fields from the given type's shape optional (default nil).
iex> Z.map(%{line_1: Z.string(), line_2: Z.string()})
iex> |> Z.partial()
iex> |> Map.get(:shape)
%{
line_1: %Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: nil},
line_2: %Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: nil}
}
iex> Z.struct(Address, %{line_1: Z.string(), line_2: Z.string()})
iex> |> Z.partial()
iex> |> Map.get(:shape)
%{
line_1: %Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: nil},
line_2: %Zodish.Type.Optional{inner_type: %Zodish.Type.String{}, default: nil}
}
"""
@spec partial(type) :: type
when type: Zodish.Type.Map.t() | Zodish.Type.Struct.t() | shaped()
def partial(%_{shape: %{}} = type) do
shape =
type.shape
|> Enum.map(&{elem(&1, 0), optional(elem(&1, 1))})
|> Enum.into(%{})
%{type | shape: shape}
end
@doc ~S"""
Keeps only the specified keys from the type's shape.
iex> Z.map(%{name: Z.string(), age: Z.integer()})
iex> |> Z.pick([:name])
iex> |> Z.parse(%{name: "John Doe"})
{:ok, %{name: "John Doe"}}
iex> Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex> |> Z.pick([:city, :state, :zip])
iex> |> Z.parse(%{
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
city: "Springfield",
state: "IL",
zip: "62701"
}}
"""
@spec pick(type, keys :: [atom()]) :: TMap.t()
when type: TMap.t()
@spec pick(type, keys :: [atom()]) :: TStruct.t()
when type: TStruct.t()
def pick(%TMap{} = type, keys) when is_list(keys), do: %{type | shape: Map.take(type.shape, keys)}
def pick(%TStruct{} = type, keys) when is_list(keys), do: %{type | shape: Map.take(type.shape, keys)}
@doc ~S"""
Defines a record type.
iex> Z.record()
iex> |> Z.parse(%{"foo" => "bar"})
{:ok, %{"foo" => "bar"}}
## Options
You can use the option `:keys` to default a schema for the keys in
the record.
iex> Z.record(keys: Z.string(min: 1))
iex> |> Z.parse(%{foo: "bar"})
{:error, %Zodish.Issue{
path: [],
message: "one or more fields failed validation",
parse_score: 1,
issues: [%Zodish.Issue{path: ["foo"], message: "expected a string, got atom"}]
}}
Although you can specify a schema for the keys, it must be a string
type.
iex> Z.record(keys: Z.integer())
** (ArgumentError) Record keys must be string
You can use the option `:values` to set a schema that will be used
to parse the values in the record.
iex> Z.record(values: Z.string(min: 1))
iex> |> Z.parse(%{"foo" => ""})
{:error, %Zodish.Issue{
path: [],
message: "one or more fields failed validation",
parse_score: 1,
issues: [%Zodish.Issue{path: ["foo"], message: "expected string to have at least 1 character, got 0 characters"}],
}}
Alternatively you can pass a type as single argument to `Z.record/1`
where it will be used as the :values types:
iex> Z.record(Z.string(min: 1))
iex> |> Z.parse(%{"foo" => ""})
{:error, %Zodish.Issue{
path: [],
message: "one or more fields failed validation",
parse_score: 1,
issues: [%Zodish.Issue{path: ["foo"], message: "expected string to have at least 1 character, got 0 characters"}],
}}
"""
@spec record(opts :: [option]) :: TRecord.t()
when option:
{:keys, Zodish.Type.t()}
| {:values, Zodish.Type.t()}
def record(opts \\ [])
def record(%_{} = type), do: TRecord.new(values: type)
def record(opts), do: TRecord.new(opts)
@doc ~S"""
Refines a value with a custom validation.
iex> is_even = fn x -> rem(x, 2) == 0 end
iex>
iex> Z.integer()
iex> |> Z.refine(is_even)
iex> |> Z.parse(3)
{:error, %Zodish.Issue{message: "is invalid", parse_score: 1}}
## Options
You can use the options `:error` to set a custom error message that
will be used when the validation fails.
iex> is_even = fn x -> rem(x, 2) == 0 end
iex>
iex> Z.integer()
iex> |> Z.refine(is_even, error: "must be even")
iex> |> Z.parse(3)
{:error, %Zodish.Issue{message: "must be even", parse_score: 1}}
"""
@spec refine(inner_type, fun, opts :: [option]) :: Refine.t()
when inner_type: Zodish.Type.t(),
fun: (any() -> boolean()) | mfa(),
option: {:error, String.t()}
defdelegate refine(inner_type, fun, opts \\ []), to: Refine, as: :new
@doc ~S"""
Switches the mode of the given schema to :strict, where additional
fields are not allowed.
iex> Z.strict(Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> }))
iex> |> Z.parse(%{
iex> name: "John Doe",
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:error, %Zodish.Issue{
message: "one or more fields failed validation",
parse_score: 6,
issues: [%Zodish.Issue{path: ["name"], message: "unknown field"}]
}}
Worth noting that :strict is the default mode for `Z.struct/2`.
"""
@spec strict(Zodish.Type.Map.t()) :: Zodish.Type.Map.t()
@spec strict(Zodish.Type.Struct.t()) :: Zodish.Type.Struct.t()
def strict(%TMap{} = type), do: TMap.strict(type)
def strict(%TStruct{} = type), do: TStruct.strict(type)
@doc ~S"""
Defines a string type.
iex> Z.string()
iex> |> Z.parse("Hello, World!")
{:ok, "Hello, World!"}
## Options
You can use `:length`, `:min` and `:max` to
constrain the length of the string.
iex> Z.string(length: 3)
iex> |> Z.parse("foobar")
{:error, %Zodish.Issue{message: "expected string to have exactly 3 characters, got 6 characters"}}
iex> Z.string(min: 1)
iex> |> Z.parse("")
{:error, %Zodish.Issue{message: "expected string to have at least 1 character, got 0 characters"}}
iex> Z.string(max: 3)
iex> |> Z.parse("foobar")
{:error, %Zodish.Issue{message: "expected string to have at most 3 characters, got 6 characters"}}
You can also use `:trim` to trim leading and trailing whitespaces
from the string before validation.
iex> Z.string(trim: true, min: 1)
iex> |> Z.parse(" ")
{:error, %Zodish.Issue{message: "expected string to have at least 1 character, got 0 characters"}}
You can use `:starts_with` and `:ends_with` to check if the string
starts with a given prefix or ends with a given suffix.
iex> Z.string(starts_with: "sk_")
iex> |> Z.parse("pk_123")
{:error, %Zodish.Issue{message: "expected string to start with \"sk_\", got \"pk_123\""}}
iex> Z.string(ends_with: "bar")
iex> |> Z.parse("fizzbuzz")
{:error, %Zodish.Issue{message: "expected string to end with \"bar\", got \"fizzbuzz\""}}
You can use `:regex` to validate the string against a regular
expression.
iex> Z.string(regex: ~r/^\d+$/)
iex> |> Z.parse("123abc")
{:error, %Zodish.Issue{message: "expected string to match /^\\d+$/, got \"123abc\""}}
You can use `:coerce` to cast the given value into a string before
validation.
iex> Z.string(coerce: true)
iex> |> Z.parse(123)
{:ok, "123"}
"""
@spec string(opts :: [option]) :: TString.t()
when option:
{:coerce, boolean()}
| {:trim, boolean()}
| {:downcase, boolean()}
| {:upcase, boolean()}
| {:length, non_neg_integer()}
| {:length, Zodish.Option.t(non_neg_integer())}
| {:min, non_neg_integer()}
| {:min, Zodish.Option.t(non_neg_integer())}
| {:max, non_neg_integer()}
| {:max, Zodish.Option.t(non_neg_integer())}
| {:starts_with, String.t()}
| {:starts_with, Zodish.Option.t(String.t())}
| {:ends_with, String.t()}
| {:ends_with, Zodish.Option.t(String.t())}
| {:regex, Regex.t()}
| {:regex, Zodish.Option.t(Regex.t())}
defdelegate string(opts \\ []), to: TString, as: :new
@doc ~S"""
Switches the mode of the given schema to :strip, where additional
fields are ignored.
iex> Z.strip(Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> }))
iex> |> Z.parse(%{
iex> name: "John Doe",
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
line_1: "123 Main St",
line_2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701"
}}
Worth noting that :strip is the default mode for `Z.map/1`.
"""
@spec strip(Zodish.Type.Map.t()) :: Zodish.Type.Map.t()
@spec strip(Zodish.Type.Struct.t()) :: Zodish.Type.Struct.t()
def strip(%TMap{} = type), do: TMap.strip(type)
def strip(%TStruct{} = type), do: TStruct.strip(type)
@doc ~S"""
Defines a struct type.
iex> Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex> |> Z.parse(%{
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
line_1: "123 Main St",
line_2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701"
}}
If your Zodish type includes a key that doesn't exist in the struct,
then an `ArgumentError` will be raised.
iex> Z.struct(Address, %{name: Z.string()})
** (ArgumentError) The shape key :name doesn't exist in struct ZodishTest.Address
A Zodish struct type works like a Map type in :strict mode, meaning
if a field that isn't present in the struct is provided in the input,
then it will fail validation.
iex> Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex> |> Z.parse(%{
iex> name: "John Doe",
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:error, %Zodish.Issue{
message: "one or more fields failed validation",
parse_score: 6,
issues: [%Zodish.Issue{path: ["name"], message: "unknown field"}]
}}
In `Zodish.struct/2` the schema is :strict by default, meaning that
additional fields are not allowed. If you want it to behave as
:strip, like it's available to `Zodish.map/2`, you can use on of the
following options:
**Option 1**:
iex> Z.struct([module: Address, mode: :strip], %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> })
iex> |> Z.parse(%{
iex> name: "John Doe",
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
line_1: "123 Main St",
line_2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701"
}}
**Option 2**:
iex> Z.strip(Z.struct(Address, %{
iex> line_1: Z.string(),
iex> line_2: Z.string(),
iex> city: Z.string(),
iex> state: Z.string(),
iex> zip: Z.string(),
iex> }))
iex> |> Z.parse(%{
iex> name: "John Doe",
iex> line_1: "123 Main St",
iex> line_2: "Apt 4B",
iex> city: "Springfield",
iex> state: "IL",
iex> zip: "62701"
iex> })
{:ok, %Address{
line_1: "123 Main St",
line_2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701"
}}
"""
@spec struct(mod, shape) :: TStruct.t()
when mod: module,
shape: %{required(atom()) => Zodish.Type.t()}
@spec struct([option, ...], shape) :: TStruct.t()
when option: {:module, module} | {:mode, :strict | :strip},
shape: %{required(atom()) => Zodish.Type.t()}
defdelegate struct(mod_or_opts, shape), to: TStruct, as: :new
@doc ~S"""
Transforms the parsed value using a given function.
iex> Z.integer()
iex> |> Z.transform(fn x -> x * 2 end)
iex> |> Z.parse(3)
{:ok, 6}
Alternatively, a `{mod, fun, args}` tuple can be provided indicating
what function should be invoked. The function is invoked with the
Zodish value as its first argument followed by the `args` you
provided.
iex> Z.integer()
iex> |> Z.transform({Integer, :to_string, []})
iex> |> Z.parse(10)
{:ok, "10"}
"""
@spec transform(inner_type, fun) :: Transform.t()
when inner_type: Zodish.Type.t(),
fun: (any() -> any())
defdelegate transform(inner_type, fun), to: Transform, as: :new
@doc ~S"""
Defines a tuple type.
iex> Z.tuple([Z.atom(), Z.integer()])
iex> |> Z.parse({:ok, 123})
{:ok, {:ok, 123}}
iex> Z.tuple([Z.atom(), Z.integer(), Z.string()])
iex> |> Z.parse({:ok, "abc"})
{:error, %Zodish.Issue{message: "expected a tuple of length 3, got length 2"}}
iex> Z.tuple([Z.atom(), Z.integer()])
iex> |> Z.parse({:ok, "abc"})
{:error, %Zodish.Issue{
message: "one or more elements of the tuple did not match the expected type",
parse_score: 1,
issues: [%Zodish.Issue{path: ["1"], message: "expected an integer, got string"}],
}}
"""
@spec tuple(elements :: [Zodish.Type.t(), ...]) :: TTuple.t()
defdelegate tuple(elements), to: TTuple, as: :new
@doc ~S"""
Defines a union type of 2 or more schemas.
iex> Z.union([
iex> Z.string(),
iex> Z.integer()
iex> ])
iex> |> Z.parse("Hello, World!")
{:ok, "Hello, World!"}
iex> Z.union([
iex> Z.string(),
iex> Z.integer()
iex> ])
iex> |> Z.parse(23.45)
{:error, %Zodish.Issue{message: "expected an integer, got float"}}
The resulting error will be from the schema which made the most
progress parsing the value.
iex> a = Z.map(%{foo: Z.string(), bar: Z.integer(), baz: Z.boolean()})
iex> b = Z.map(%{foo: Z.string(), qux: Z.float()})
iex>
iex> Z.union([a, b])
iex> |> Z.parse(%{foo: "Hello", bar: 123})
{:error, %Zodish.Issue{message: "one or more fields failed validation", parse_score: 3, issues: [
%Zodish.Issue{path: ["baz"], message: "is required"}
]}}
> #### Warning {: .warning}
>
> The logic for selecting the best schema validation issues is still
> a work in progress and may be changed in the future.
"""
@spec union(inner_types) :: TUnion.t()
when inner_types: [Zodish.Type.t(), ...]
defdelegate union(schemas), to: TUnion, as: :new
@doc ~S"""
Defines a string URI type.
iex> Z.uri()
iex> |> Z.parse("https://foo.bar/")
{:ok, "https://foo.bar/"}
## Options
You can constrain which schemes are allowed by passing the
`:schemes` option:
iex> Z.uri(schemes: ["https"])
iex> |> Z.parse("http://localhost/")
{:error, %Zodish.Issue{message: "scheme not allowed"}}
You can trim the trailing slash in the uri by passing the
`:trailing_slash` option:
iex> Z.uri(trailing_slash: :trim)
iex> |> Z.parse("https://foo.bar/api/")
{:ok, "https://foo.bar/api"}
Likewise, you can enfore the trailing slash:
iex> Z.uri(trailing_slash: :enforce)
iex> |> Z.parse("https://foo.bar/api")
{:ok, "https://foo.bar/api/"}
"""
@spec uri([option]) :: TUri.t()
when option:
{:schemes, [String.t()]}
| {:trailing_slash, :keep | :trim | :enforce}
defdelegate uri(opts \\ []), to: TUri, as: :new
@doc ~S"""
Defines a UUID type (decorated String type).
iex> Z.uuid()
iex> |> Z.parse("550e8400-e29b-41d4-a716-446655440000")
{:ok, "550e8400-e29b-41d4-a716-446655440000"}
You can optionally specify which version of UUID to validate for.
iex> Z.uuid(:any)
iex> |> Z.parse("550e8400-e29b-41d4-a716-446655440000")
{:ok, "550e8400-e29b-41d4-a716-446655440000"}
iex> Z.uuid(:v4)
iex> |> Z.parse("67ef5479-e5c2-411f-9cfc-82ff3c17a76e")
{:ok, "67ef5479-e5c2-411f-9cfc-82ff3c17a76e"}
iex> Z.uuid(:v7)
iex> |> Z.parse("019798df-04e0-7279-8bca-26f70bb361d2")
{:ok, "019798df-04e0-7279-8bca-26f70bb361d2"}
There are 9 supported options: `:any` (default), `:v1`, `:v2`, `:v3`,
`:v4`, `:v5`, `:v6`, `:v7` and `:v8`.
"""
defdelegate uuid(version \\ :any), to: TUuid, as: :new
#
# PRIVATE
#
defp most_strict(:strict, _), do: :strict
defp most_strict(_, :strict), do: :strict
defp most_strict(:strip, :strip), do: :strip
end