Modern JSON Schema validation for Erlang/OTP.
valid_json is an Erlang/OTP library that validates JSON instances against
JSON Schema Draft 6, Draft 7, Draft 2019-09, and Draft 2020-12, including
references across all four dialects. Schemas are compiled once when they are
registered and are then validated against in one of the four standard output
formats. The registry is offline — no network requests are made during
validation.
valid_json fills the niche of a modern JSON Schema validator for Erlang.
jesse, the incumbent Erlang validator, has
not followed the specification past draft 06. jsonschex
implements Draft 2020-12 in full, but it is an Elixir library: using it from
Erlang brings the Elixir toolchain and a struct-shaped API into your build.
If you are writing Erlang and need Draft 6 through Draft 2020-12, valid_json
provides JSON Schema support without requiring an Elixir-based validation
stack.
| Feature | valid_json | jesse | jsonschex |
|---|---|---|---|
| Erlang-native | yes | yes | no (Elixir) |
| Draft 2020-12 | yes | no (drafts 03, 04, 06) | yes |
| Draft 2019-09 | yes | no | no |
| Draft 7 / Draft 6 | yes / yes | no / yes | no / no |
| Cross-draft references | yes, all four dialects | no | no |
$dynamicRef / $recursiveRef |
yes | no | $dynamicRef only |
unevaluatedProperties / unevaluatedItems |
yes | no | yes |
| Standard output formats | flag, basic, detailed, verbose |
own error tuples | own error structs |
| Runtime dependencies | none | none | optional jason, decimal, idna |
| Network at validation time | never | possible for unknown $ref |
whatever your loader does |
| Command-line tool | valid-json check DIR |
yes, escript and Docker | no |
See the full comparison in docs/comparison — API, architecture, and keyword-by-keyword coverage, measured against jesse 1.8.2 and jsonschex 0.9.0.
The package is not published on Hex yet; add it as a Git dependency, pinned to the latest tag:
{deps, [
{valid_json, {git, "https://github.com/Regikul/valid_json.git", {tag, "v0.4.0"}}}
]}.Start the application so that the built-in meta-schemas are published, then validate.
run_schema/3 compiles the schema in the calling process and validates the
instance right away — nothing is kept:
{ok, _} = application:ensure_all_started(valid_json),
Schema = #{<<"type">> => <<"integer">>, <<"minimum">> => 0},
{ok, #{<<"valid">> := true}} =
valid_json:run_schema(Schema, 5, [{output, flag}]).valid_json_schema_set:check/2 verifies a complete set of named schema
documents without starting the valid_json application, creating ETS tables,
or retaining compiled artifacts. Register the whole set in one call so that
references between its documents can be resolved:
Entries = [
{<<"root.json">>,
#{<<"$schema">> => <<"https://json-schema.org/draft/2020-12/schema">>,
<<"$ref">> => <<"definitions.json">>}},
{<<"definitions.json">>,
#{<<"$schema">> => <<"https://json-schema.org/draft/2020-12/schema">>,
<<"type">> => <<"object">>}}
],
{ok, Names} =
valid_json_schema_set:check(
Entries, [{base_uri, <<"https://example.com/schemas/">>}]).The call accepts base_uri, default_dialect, and schema_validation options.
It returns registration errors separately from schema-validation errors, each
paired with the corresponding document URI. Reading and decoding files remains
the caller's responsibility.
Register the schema under its $id, then validate by name. Registration
compiles the schema; every validation after that is a lookup:
{ok, _} = application:ensure_all_started(valid_json),
Name = <<"https://example.com/schemas/user">>,
Schema = #{
<<"$id">> => Name,
<<"type">> => <<"object">>,
<<"properties">> => #{
<<"name">> => #{<<"type">> => <<"string">>}
},
<<"required">> => [<<"name">>]
},
{ok, [CanonicalUri]} = valid_json:add(Schema),
{ok, #{<<"valid">> := true}} =
valid_json:validate(
CanonicalUri,
#{<<"name">> => <<"Ada">>},
[{output, flag}]).Registered schemas are compiled once and reused. Artifacts are stored in a
supervised ETS table, so a schema used more than once belongs in a store; a
schema that arrives with a single request can use run_schema/3, at the price
of compiling it on every call.
rebar3 escriptize builds valid-json, a self-contained escript: the built-in
meta-schemas are embedded at compile time, so the command needs neither the
application nor the network.
rebar3 escriptize
_build/default/bin/valid-json check priv/schemascheck DIRECTORY reads every .json document below the directory and checks it
against its meta-schema. The documents are registered together, so a $ref
between them resolves. A schema is named by its own $id, and by its path below
the directory when it has none. --default-dialect URI names the dialect for
documents that declare no $schema.
Streams are split by what the command is for. Validation results go to stdout as
one standard output document per line — the specification's detailed format,
one line per schema that failed, each naming its subject in an extra instance
member:
{
"instance": "file:///srv/schemas/user.json",
"valid": false,
"keywordLocation": "",
"instanceLocation": "",
"absoluteKeywordLocation": "https://json-schema.org/draft/2020-12/schema#",
"errors": [
{
"valid": false,
"keywordLocation": "/allOf/3/$ref/properties/minimum/type",
"absoluteKeywordLocation":
"https://json-schema.org/draft/2020-12/meta/validation#/properties/minimum/type",
"instanceLocation": "/minimum",
"error": "expected number, got string"
}
]
}Pretty-printed here; the stream puts each document on a single line.
Everything the command could not check — a bad argument, an unreadable directory, a document that would not register or compile — goes to stderr as prose. The exit code follows the same split:
| code | meaning |
|---|---|
| 0 | every schema passed; nothing is printed |
| 1 | a schema failed its meta-schema; stdout carries the output documents |
| 2 | something could not be checked; stderr says what |
An empty directory is exit code 2, not 0: silence there would be indistinguishable from a checked set.
- JSON Schema Draft 2020-12
- JSON Schema Draft 2019-09
- JSON Schema Draft 7
- JSON Schema Draft 6
- Cross-draft references between all four
$id,$anchor,$defs,definitions,$ref- Draft 6/7 fragment-only
$idtargets and their$ref-only sibling semantics $dynamicRef/$dynamicAnchor(Draft 2020-12)$recursiveRef/$recursiveAnchor(Draft 2019-09)$vocabulary, built-in meta-schemas, and user-provided meta-schemas from the store (vocabularies begin with Draft 2019-09)- References are resolved eagerly at compile time, so a reference closure is a finite, comparable value — cyclic schemas are not a problem
- All standard assertion keywords:
type,enum,const, numeric bounds,pattern, length and collection-size keywords,uniqueItems,required,dependentRequired, plus both forms of Draft 6/7dependencies - Applicators:
allOf,anyOf,oneOf,not,if/then/else,dependentSchemas(if/then/elsebegin with Draft 7) - Object applicators:
properties,patternProperties,additionalProperties,propertyNames - Array applicators:
prefixItems,items,contains,minContains,maxContains, plus the Draft 6/7/2019-09 array form ofitemsandadditionalItems unevaluatedPropertiesandunevaluatedItems
- The four standard output formats of the specification:
flag,basic,detailed, andverbose
- Schemas are compiled once on registration; artifacts live in a supervised ETS table
- Transactional reload and removal with dependency checking — a document that others reference cannot be removed
- A directory loader reads
.jsonfiles recursively at startup run_schema/3for schemas that are used once
The registry is deliberately offline: documents reachable through $ref must
be registered before compilation. No network requests are made at runtime, so
validation is deterministic, has no latency from fetching, and exposes no
fetching surface for untrusted schemas.
A document is addressed by its $id; a schema that declares none can be named
from the outside with add_at/1,2 or by the loader. Sets of documents that
reference one another are registered in one call, because references are
resolved eagerly:
{ok, [CanonicalUriA, CanonicalUriB]} = valid_json:add([SchemaA, SchemaB]).The full identifier model — $id, base_uri, relative names, embedded
resources, and the directory loader — is described in
Schema resources and identifiers.
Validation returns the standard output document of the specification, so {ok, Output} may well describe a failed validation — valid is a field inside
Output, not the shape of the return. The address itself may be a relative,
short name: on a miss it is resolved against the store's base_uri before
reporting not_found.
{ok, #{<<"valid">> := false, <<"errors">> := [_ | _]}} =
valid_json:validate(RelativelUri, #{<<"name">> => 42}, [{output, detailed}]).{error, Reason} is reserved for not getting as far as evaluating:
not_found, unavailable, or an evaluation error.
valid_json runs the declared validation profile from the official
JSON Schema Test Suite
for all four dialects. The pinned conformance run executes 1355 test groups
and 6125 test cases, plus the 8 official output test cases (standardized
only for Draft 2019-09 and Draft 2020-12) and 58 remote documents used by
refRemote tests. Remote documents are compiled under their own $schema, so
the run also verifies cross-draft resolution; they are registered in advance,
and validation makes no network requests.
The declared capability profiles include:
optional/bignum,optional/id,optional/non-bmp-regex, andoptional/unknownKeywordin all four dialects;optional/anchorandoptional/no-schemain Draft 2019-09 and Draft 2020-12,optional/cross-draftwhere the suite supplies it, and the Draft 2020-12optional/dynamicRefprofile;- a
formatprofile compiled with{assert_format, true}: 10 files in Draft 6, 14 in Draft 7, and 16 each in Draft 2019-09 and Draft 2020-12. The four IDN/IRI files and the A-label group are declared exclusions where present; - the official output tests, which pin the
basicformat;flag,detailed, andverboseare covered by the project's own golden tests, because the official suite does not exercise them.
Every schema resource is checked against its own meta-schema when it is
compiled by default; a schema that fails its meta-schema is rejected at
registration. A caller that has already verified its schemas may pass
{trust_schema, true} to run_schema/3 or configure the same option on a
store. This skips only meta-schema evaluation: dialect and vocabulary
resolution, reference checks, regex compilation, emitter safety checks, and
instance validation still run. schema_validation selects the diagnostic
format when the check is enabled.
trust_schema is a store-wide policy. It applies equally to loader startup,
later additions, rebuilds, and recovery after an artifact-table restart; use
separate stores for trusted and untrusted sources.
The conformance policy, including the exact list of files, excluded groups, and
the pinned census, lives in okf/testing/conformance-policy.md.
format is collected as an annotation by default in all four dialects.
Format assertions are opt-in: compiling with {assert_format, true} enables
string checking for the implemented formats. An annotation is still collected
for a passing value, and a value of a non-string type always passes.
Schema = #{<<"format">> => <<"ipv4">>},
%% Annotation only: the string passes, whatever it contains.
{ok, #{<<"valid">> := true}} =
valid_json:run_schema(Schema, <<"999.1.1.1">>, []),
%% With assertions enabled, the value is checked.
{ok, #{<<"valid">> := false}} =
valid_json:run_schema(Schema, <<"999.1.1.1">>,
[{assert_format, true}]).| Formats | |
|---|---|
Assertion (with assert_format) |
date, time, date-time, duration, ipv4, ipv6, hostname, email, uri, uri-reference, uri-template, json-pointer, relative-json-pointer, uuid, regex — 15 formats |
| Annotation only, by declaration | idn-email, idn-hostname, iri, iri-reference |
Unknown format names always pass and still produce an annotation. The
Format-Assertion vocabulary is not claimed: a meta-schema that declares it
true is rejected, as the specification requires of an implementation that
does not check every format name. contentEncoding, contentMediaType, and
contentSchema are annotations and do not decode or validate string content.
The per-format algorithms and their exact boundaries are documented in okf/architecture/format-attributes.md.
- Regular expression dialect.
pattern,patternProperties, andformat: regexare compiled with Erlang'sremodule, which is PCRE rather than ECMA-262. A pattern outside the subset shared by both dialects — for example\p{Letter}— fails to compile and the whole schema is rejected. The ECMA-262/PCRE differences are measured in okf/architecture/ecma-to-pcre-adaptation.md. - IDN and IRI formats.
idn-email,idn-hostname,iri, andiri-referenceare always annotations: the string itself is not checked. This is a declared exclusion of the profile, not a temporary gap. hostnameand A-labels. Anxn--…label is treated as an ordinary LDH label: A-labels are not decoded and IDNA2008 rules are not applied to their contents.- Numeric precision.
multipleOfand numeric comparisons are computed on doubles, so a decimal fraction with no exact binary representation can disagree with decimal arithmetic. - Content keywords.
contentEncoding,contentMediaType, andcontentSchemaare annotations only; string content is not decoded.
- Comparison with jesse and jsonschex — the full checklist, split into API, architecture, and keyword coverage
- Schema resources and identifiers — naming, the registry, the loader, and custom stores
- okf/ — normative documents: architecture, core contract, conformance policy, and format attributes
- ROADMAP.md — the implementation checklist, phase by phase
- Erlang/OTP 20 or later. CI runs the full suite (compile, conformance, EUnit) on OTP 20 through 29.
- rebar3
OTP releases before 27 use vendored copies of the stdlib json and uri_string
modules, taken from OTP 28.1.1 and compiled only on the old releases. This is
what keeps {deps, []} empty — the library has no third-party dependencies, on
any OTP version. See THIRD_PARTY.md.
rebar3 compile
rebar3 eunit
rebar3 conformanceconformance is an alias that runs the conformance profiles alone — the JSON
Schema Test Suite and the official output tests — without the remaining unit
tests. In CI, the ci profile turns compiler warnings into errors and runs
rebar3 as ci compile, rebar3 as ci conformance, and rebar3 as ci eunit.
valid_json is version 0.4.0 and is under active development. Draft 6, Draft
7, Draft 2019-09, and Draft 2020-12 are supported within the conformance
profile declared above; the remaining work is tracked in
ROADMAP.md — the Format-Assertion vocabulary of phase P8, the
HTTP loader, and the cross-cutting items.
The records' reason and location fields are the stable error contract; the
wording produced by format_error/1 is an implementation detail and may change.
The public API may have breaking changes before 1.0.
Licensed under the Apache License 2.0.