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.
Add to rebar.config:
{deps, [{zz, "0.1.0"}]}.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]}).A parser is a fun((term()) -> {ok, term()} | {error, [term()]}). Run it
via zz:parse/2.
zz:atom().
%% {error, [not_atom]} on non-atom input.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.
zz:boolean().
%% {error, [not_boolean]} on non-boolean.zz:integer().
zz:integer(#{min => N, max => N}).Errors: not_integer, integer_too_small, integer_too_large.
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.
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 parsersErrors: 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.
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:
strip(default formap/1,2) — drop keys not inSchemafrom output.passthrough(default formap/0) — keep unknown keys in output.strict— emit{unknown_keys, [Key]}error.
Errors: not_map, {map, Key, missing_key}, {map, Key, InnerErrors},
{unknown_keys, [Key]}.
zz:literal(42).
zz:literal(<<"hello">>).
%% Matches with =:=. {error, [not_literal]} otherwise.zz:tuple(). %% any tuple, contents not validated
zz:tuple([zz:integer(), zz:binary()]). %% fixed-arity, per-position parsersErrors: not_tuple, arity_mismatch. Element errors are wrapped as
{tuple, Index, InnerErrors} with 1-based Index.
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, []}]}.
zz:optional(Parser) wraps a parser for use as a map schema value. Has no
effect outside a zz:map/1,2 schema.
zz:transform(Parser, Fun) runs Parser, then applies Fun to the
parsed value on success. Errors pass through unchanged.
zz:transform(zz:binary(), fun binary_to_atom/1).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]}]}]}
%% ]}zz:issues/1 flattens errors into a list of #{path, code} maps:
{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.
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