# zz
[](https://github.com/denizdogan/zz/actions/workflows/ci.yml)
[](https://codecov.io/gh/denizdogan/zz)
[](LICENSE.md)
[Zod](https://zod.dev/)-like parsing and validation for Erlang.
`zz` provides composable parser combinators that validate runtime data
against a schema and return either `{ok, Output}` or `{error, Errors}`
with structured error paths.
The name and the API are directly inspired by Zod. It would have been
just `z`, but Hex requires package names to be at least two characters
long.
## Installation
Add to `rebar.config`:
```erlang
{deps, [{zz, "0.2.1"}]}.
```
## Quick start
```erlang
Z = zz:map(#{
name => zz:binary(),
age => zz:integer(#{min => 0}),
tags => zz:list(zz:atom())
}),
%% Valid input -> {ok, ParsedMap}.
{ok, User} = zz:parse(Z, #{name => <<"alice">>, age => 30, tags => [admin]}),
<<"alice">> = maps:get(name, User),
30 = maps:get(age, User),
[admin] = maps:get(tags, User),
%% Invalid input -> {error, Errors}.
{error, _Errs} = zz:parse(Z, #{name => 1, age => -1, tags => [admin]}).
%% Errs = [
%% {map_value, name, [not_binary]},
%% {map_value, age, [integer_too_small]}
%% ]
```
## API
A parser is a `t:zz:parser/1` — a function from input to `{ok, Value} |
{error, Errors}`. Run it via `zz:parse/2`.
### Any
```erlang
zz:any(). %% accepts anything; output = input
```
### Atoms
```erlang
zz:atom().
%% {error, [not_atom]} on non-atom input.
```
### Binaries
```erlang
zz:binary().
zz:binary(#{min => Min, max => Max, regex => Pattern}).
```
Errors: `not_binary`, `binary_too_short`, `binary_too_long`,
`regex_mismatch`. `min` and `max` measure `byte_size/1`. `regex` accepts
any `re:run/2`-compatible pattern.
### Bitstrings
```erlang
zz:bitstring().
zz:bitstring(#{min => MinBits, max => MaxBits}).
```
Errors: `not_bitstring`, `bitstring_too_short`, `bitstring_too_long`.
`min` and `max` measure `bit_size/1`.
### Booleans
```erlang
zz:boolean().
%% {error, [not_boolean]} on non-boolean.
```
### Characters and char lists
```erlang
zz:char(). %% single Unicode codepoint, integer in 0..16#10FFFF
zz:char_list(). %% [char()] — old-style Erlang string
```
Errors: `not_char`, `not_list`. Element errors in `char_list` are
wrapped as `{list, Index, [not_char]}` with 1-based `Index`.
### Integers
```erlang
zz:integer().
zz:integer(#{min => Min, max => Max}).
```
Errors: `not_integer`, `integer_too_small`, `integer_too_large`.
Typed shortcuts:
```erlang
zz:pos_integer(). %% >= 1; {error, [not_pos_integer]}
zz:non_neg_integer(). %% >= 0; {error, [not_non_neg_integer]}
zz:neg_integer(). %% =< -1; {error, [not_neg_integer]}
```
### Floats
```erlang
zz:float().
zz:float(#{min => Min, max => Max}).
```
Errors: `not_float`, `float_too_small`, `float_too_large`. Integers are
not accepted — use `zz:number()` for either.
### Numbers
```erlang
zz:number(). %% integer or float
%% {error, [not_number]} otherwise.
```
### Iodata and iolists
```erlang
zz:iodata(). %% binary or iolist
zz:iolist(). %% iolist only (binary input rejected)
```
Errors: `not_iodata`, `not_iolist`.
### Lists
```erlang
zz:list(). %% any list, contents not validated
zz:list(zz:integer()). %% homogeneous list
zz:list(zz:integer(), #{min => 1, max => 10}). %% with length options
```
Errors: `not_list`, `list_too_short`, `list_too_long`. Element errors
are wrapped as `{list, Index, InnerErrors}` with 1-based `Index`.
### Maps
```erlang
zz:map(). %% any map, passthrough
zz:map(Schema). %% schema with default unknown_keys => strip
zz:map(Schema, #{unknown_keys => strip | passthrough | strict}).
```
`Schema` is a map of `Key => Parser | {optional, Parser}`. Use
`zz:optional/1` to mark optional keys:
```erlang
zz:map(#{
id => zz:integer(),
nickname => zz:optional(zz:binary())
}).
```
`unknown_keys` modes:
- `strip` (default for `map/1,2`) — drop keys not in `Schema` from output.
- `passthrough` (default for `map/0`) — keep unknown keys in output.
- `strict` — emit `{unknown_keys, [Key]}` error.
Errors: `not_map`, `{map_missing, Key}`, `{map_value, Key, InnerErrors}`,
`{unknown_keys, [Key]}`.
For arbitrary-keyed homogeneous maps, use `zz:map_of(KeyParser,
ValueParser)`:
```erlang
zz:map_of(zz:binary(), zz:integer()).
```
Key errors are wrapped as `{map_key, OriginalKey, InnerErrors}`; value
errors as `{map_value, OriginalKey, InnerErrors}`.
### Literals
```erlang
zz:literal(42).
zz:literal(<<"hello">>).
%% Matches with =:=. {error, [not_literal]} otherwise.
```
### Tuples
```erlang
zz:tuple(). %% any tuple, contents not validated
zz:tuple({zz:integer(), zz:binary()}). %% fixed-arity, per-position parsers
```
Errors: `not_tuple`, `arity_mismatch`. Element errors are wrapped as
`{tuple, Index, InnerErrors}` with 1-based `Index`.
### Unions
```erlang
zz:union([zz:integer(), zz:binary()]).
%% First parser to succeed wins.
```
If no branch matches, the error is
`{error, [{no_match, [Errors1, Errors2, ...]}]}` where each entry is the
errors list from the corresponding parser, in input order. Empty union
yields `{error, [{no_match, []}]}`.
### Enums
```erlang
zz:enum([red, green, blue]).
%% {error, [not_in_enum]} on any value not in the list.
```
Sugar for "input must be `=:=` one of these values". Equivalent to a
union of `literal/1`s but with a flat error code.
### Pids and references
```erlang
zz:pid(). %% {error, [not_pid]} on non-pid
zz:reference(). %% {error, [not_reference]} on non-reference
```
### Functions
```erlang
zz:function(). %% any function
zz:function(2). %% function with arity 2
```
Errors: `not_function`, `function_arity_mismatch`.
### Optional
`zz:optional(Parser)` marks a key as optional inside a `zz:map/1,2`
schema. Not a standalone parser — calling `zz:parse/2` on the result
directly crashes.
### Nullable
`zz:nullable(Parser)` accepts `undefined` alongside `Parser`'s values.
Sugar for `union([literal(undefined), Parser])`.
### Lazy
`zz:lazy(fun() -> Parser end)` defers parser construction until parse
time. Use it to build self-referential (recursive) schemas. The thunk
runs on every descent, so keep it cheap.
Binary tree:
```erlang
tree() ->
zz:union([
zz:literal(leaf),
zz:tuple({
zz:literal(node),
zz:lazy(fun() -> tree() end),
zz:lazy(fun() -> tree() end)
})
]).
```
Tree with arbitrary children — a label and a list of child nodes:
```erlang
node_tree() ->
zz:tuple({
zz:atom(),
zz:list(zz:lazy(fun() -> node_tree() end))
}).
```
## Error format
Errors are a list. Each entry is either a leaf atom (`not_atom`,
`integer_too_small`, ...) or a tagged tuple locating the failure inside a
nested structure:
```erlang
{list, Index, InnerErrors}
{tuple, Index, InnerErrors}
{map_value, Key, InnerErrors}
{map_key, Key, InnerErrors}
{map_missing, Key}
{unknown_keys, [Key]}
{no_match, [Errors1, Errors2, ...]}
```
Multiple errors at the same level accumulate.
```erlang
Z = zz:map(#{
name => zz:binary(),
friends => zz:list(zz:map(#{age => zz:integer(#{min => 0})}))
}),
zz:parse(Z, #{name => 1, friends => [#{age => -1}]}).
%% {error, [
%% {map_value, name, [not_binary]},
%% {map_value, friends, [{list, 1, [{map_value, age, [integer_too_small]}]}]}
%% ]}
```
### Flat issue list
`zz:issues/1` flattens errors into a list of `#{path, code}` maps:
```erlang
{error, Errs} = zz:parse(Z, #{name => 1, friends => [#{age => -1}]}),
zz:issues(Errs).
%% [
%% #{path => [name], code => not_binary},
%% #{path => [friends, 1, age], code => integer_too_small}
%% ]
```
Useful for JSON serialization, logging, etc.
> **Note:** Issue order for `map/1,2` and `map_of/2` follows the
> underlying map iteration order. Erlang does not guarantee a map
> iteration order across OTP releases — the observable order has
> changed as internal representations evolved, and `maps:keys/1` and
> friends explicitly document the order as undefined. zz targets OTP
> 27+; treat the order as undefined and sort by `path` if you need
> deterministic output.
### Formatted output
`zz:format_issues/1` renders issues as a human-readable binary, one
issue per line:
```erlang
zz:format_issues(zz:issues(Errs)).
%% <<"name: not_binary\nfriends.1.age: integer_too_small\n">>
```
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full setup. Quick start
with [Mise](https://mise.jdx.dev/):
```console
$ mise compile
$ mise test
$ mise check # everything: fmt, eunit, proper, dialyzer, eqwalizer
$ mise docs
```
## License
[Apache-2.0](LICENSE.md)