zz
View SourceZod-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:
{deps, [{zz, "0.2.0"}]}.Quick start
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 zz:parser/1 — a function from input to {ok, Value} | {error, Errors}. Run it via zz:parse/2.
Any
zz:any(). %% accepts anything; output = inputAtoms
zz:atom().
%% {error, [not_atom]} on non-atom input.Binaries
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
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
zz:boolean().
%% {error, [not_boolean]} on non-boolean.Characters and char lists
zz:char(). %% single Unicode codepoint, integer in 0..16#10FFFF
zz:char_list(). %% [char()] — old-style Erlang stringErrors: not_char, not_list. Element errors in char_list are
wrapped as {list, Index, [not_char]} with 1-based Index.
Integers
zz:integer().
zz:integer(#{min => Min, max => Max}).Errors: not_integer, integer_too_small, integer_too_large.
Typed shortcuts:
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
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
zz:number(). %% integer or float
%% {error, [not_number]} otherwise.Iodata and iolists
zz:iodata(). %% binary or iolist
zz:iolist(). %% iolist only (binary input rejected)Errors: not_iodata, not_iolist.
Lists
zz:list(). %% any list, contents not validated
zz:list(zz:integer()). %% homogeneous list
zz:list(zz:integer(), #{min => 1, max => 10}). %% with length optionsErrors: not_list, list_too_short, list_too_long. 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:
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_missing, Key}, {map_value, Key, InnerErrors},
{unknown_keys, [Key]}.
For arbitrary-keyed homogeneous maps, use zz:map_of(KeyParser, ValueParser):
zz:map_of(zz:binary(), zz:integer()).Key errors are wrapped as {map_key, OriginalKey, InnerErrors}; value
errors as {map_value, OriginalKey, InnerErrors}.
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 parsersErrors: 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, []}]}.
Enums
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/1s but with a flat error code.
Pids and references
zz:pid(). %% {error, [not_pid]} on non-pid
zz:reference(). %% {error, [not_reference]} on non-referenceFunctions
zz:function(). %% any function
zz:function(2). %% function with arity 2Errors: 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:
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:
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:
{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.
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:
{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,2andmap_of/2follows 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, andmaps:keys/1and friends explicitly document the order as undefined. zz targets OTP 27+; treat the order as undefined and sort bypathif you need deterministic output.
Formatted output
zz:format_issues/1 renders issues as a human-readable binary, one
issue per line:
zz:format_issues(zz:issues(Errs)).
%% <<"name: not_binary\nfriends.1.age: integer_too_small\n">>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