zz

CIcodecovLicense

Zod-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.

Installation

Add to rebar.config:

{deps, [{zz, "0.1.0"}]}.

Quick start

Z = zz:map(#{
    name => zz:binary(),
    age => zz:integer(#{min => 0}),
    tags => zz:list(zz:atom())
}),

{ok, _} = zz:parse(Z, #{name => <<"alice">>, age => 30, tags => [admin]}).

API

A parser is a fun((term()) -> {ok, term()} | {error, [term()]}). Run it via zz:parse/2.

Atoms

zz:atom().
%% {error, [not_atom]} on non-atom input.

Binaries

zz:binary().
zz:binary(#{min => N, max => N, 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.

Booleans

zz:boolean().
%% {error, [not_boolean]} on non-boolean.

Integers

zz:integer().
zz:integer(#{min => N, max => N}).

Errors: not_integer, integer_too_small, integer_too_large.

Floats

zz:float().
zz:float(#{min => N, max => N}).

Errors: not_float, float_too_small, float_too_large. Integers are not accepted — use zz:union([zz:integer(), zz:float()]) for either.

Lists

zz:list().                         %% any list, contents not validated
zz:list(zz:integer()).              %% homogeneous list
zz:list(zz:integer(), #{min => 1, max => 10}).
zz:list([zz:integer(), zz:binary()]).%% fixed-length, per-position parsers

Errors: not_list, list_too_short, list_too_long, length_mismatch (fixed-length form). Element errors are wrapped as {list, Index, InnerErrors} with 1-based Index.

Maps

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:

zz:map(#{
    id => zz:integer(),
    nickname => zz:optional(zz:binary())
}).

unknown_keys modes:

Errors: not_map, {map, Key, missing_key}, {map, Key, InnerErrors}, {unknown_keys, [Key]}.

Literals

zz:literal(42).
zz:literal(<<"hello">>).
%% Matches with =:=. {error, [not_literal]} otherwise.

Tuples

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

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, []}]}.

Optional

zz:optional(Parser) wraps a parser for use as a map schema value. Has no effect outside a zz:map/1,2 schema.

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:

{list, Index, InnerErrors}
{tuple, Index, InnerErrors}
{map, Key, InnerErrors}
{map, Key, missing_key}
{unknown_keys, [Key]}
{no_match, [Errors1, Errors2, ...]}

Multiple errors at the same level accumulate.

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, name, [not_binary]},
%%     {map, friends, [{list, 1, [{map, age, [integer_too_small]}]}]}
%% ]}

Development

See CONTRIBUTING.md for the full setup. Quick start with Mise:

$ mise compile
$ mise test
$ mise check      # everything: fmt, eunit, proper, dialyzer, eqwalizer
$ mise docs
$ git config --local blame.ignoreRevsFile .git-blame-ignore-revs

License

Apache-2.0