Getting started

Set up, validate, and ship it.

From install to a validated document, then everything the library gives you once it is carrying production traffic: stable error codes, problem+json responses, message catalogs, observability hooks, streaming, and a CLI for CI.

1. Install

Requires Go 1.24 or newer. There are no third-party dependencies.

terminal
go get github.com/ashbeelghouri/json-schematics-v2@latest

2. Write a schema

A schema is data. Each field targets a flattened key and lists the validate rules and operate steps to run against it.

schema.json
{
  "version": "2.0",
  "fields": [
    {
      "target": "user.profile.name",
      "required": true,
      "validate": [
        { "rule": "isString" },
        { "rule": "minLength", "args": { "min": 2 } }
      ],
      "operate": [ { "op": "trim" }, { "op": "capitalize" } ]
    }
  ]
}

3. Validate

Validate returns nil when the document is valid, a *ValidationErrors for data problems, or a *SchemaError if the schema references a rule that isn't registered. It never panics.

main.go
s := schematics.New()
if err := s.LoadFile("schema.json"); err != nil {
    log.Fatal(err)
}

if err := s.Validate(payload); err != nil {
    var ve *schematics.ValidationErrors
    if errors.As(err, &ve) {
        for _, m := range ve.Strings("en", "%target: %message") {
            fmt.Println(m)
        }
    }
}

Targeting keys

A target selects one or more flattened keys. Use a literal path, a * wildcard for a single segment (array indices), or a full regex with targetRegex: true.

user.profile.nameexact literal pathmatchesuser.profile.name
items.*.skuwildcard, one segmentmatchesitems.0.sku, items.1.sku
^user\.(name|email)$regex, targetRegex: truematchesuser.name, user.email

4. Operate

Operators transform values in a pass separate from validation. Call Operate to get the reshaped document back.

main.go
out, err := s.Operate(payload)
// out is the transformed document:
// "  ada " -> trim -> "ada" -> capitalize -> "Ada"

5. Bring your own rules

Register typed functions before you validate. Each receives the value, the schema args, and a *Context with the shared DB. Return an error to fail, never a panic. Reference them in the schema by name.

custom validator
s.RegisterRule("isSlug", func(v any, a schematics.Args, _ *schematics.Context) error {
    str, ok := v.(string)
    if !ok || !slugRe.MatchString(str) {
        return fmt.Errorf("%q is not a slug", v)
    }
    return nil
})
custom operator
s.RegisterOperator("slugify", func(v any, a schematics.Args, _ *schematics.Context) (any, error) {
    str, _ := v.(string)
    return strings.ReplaceAll(strings.ToLower(str), " ", "-"), nil
})

6. Context & the shared DB

Every rule, operator, and condition receives a *Context. It carries the active locale and separator, the whole flattened document, the current array RowID, a Go context.Context, and a DB map you can use as shared memory across fields.

context.go
type Context struct {
    Ctx    context.Context  // cancellation for slow custom rules
    DB     map[string]any   // shared memory
    Locale string
    Flat   map[string]any   // the whole flattened document
    RowID  string           // array row id, empty for objects
}

Seed the DB

Fill it with global config or request-scoped data from Go, or from the schema itself. Values are available to every field's rules.

main.go
s := schematics.New(schematics.WithDB(map[string]any{
    "minAge":       18,
    "allowedRoles": []any{"admin", "editor"},
}))

Or copy a value out of the data itself with addToDB. It is stored under the field's target key before validation runs, so any other field can read it.

schema.json
{ "target": "password", "addToDB": true,
  "validate": [ { "rule": "minLength", "args": { "min": 8 } } ] }

Cross-field validation

Read a sibling field with ctx.Lookup(target) (no addToDB needed), or read seeded config with ctx.DB[key]. This is how you express confirm-password, date ordering, or "this depends on that field's value".

custom rule
s.RegisterRule("matchesField", func(v any, a schematics.Args, ctx *schematics.Context) error {
    other, _ := a.String("field")
    want, ok := ctx.Lookup(other)
    if !ok || fmt.Sprintf("%v", v) != fmt.Sprintf("%v", want) {
        return fmt.Errorf("must match %s", other)
    }
    return nil
})
schema.json
{ "target": "confirm",
  "validate": [ { "rule": "matchesField", "args": { "field": "password" } } ] }

DB is built per document, so array rows never leak state into each other. Treat it as read-mostly: prefer Lookup or addToDB over mutating ctx.DB inside a rule, since rule order within a field is first-fail-wins. For slow rules that call a database or remote service, honor ctx.Ctx.Done() and run with s.ValidateCtx(ctx, data).

Conditions and boolean when

A field's when list decides whether the field is evaluated at all. The built-in conditions are fieldPresent, fieldAbsent, fieldEquals, fieldMatches, fieldGreaterThan, fieldLessThan, and fieldIn. A flat list is ANDed: every entry has to hold.

schema.json
{ "target": "shippingAddress", "required": true,
  "when": [
    { "condition": "fieldEquals", "args": { "field": "delivery", "value": "courier" } },
    { "condition": "fieldPresent", "args": { "field": "cart" } }
  ] }

Any entry can also be an any (OR), all (AND), or not group that composes other entries, including further nested groups. This is purely additive, so schemas written against a flat list behave exactly as they did.

or: one of two account types
{
  "target": "companyName",
  "required": true,
  "when": [
    { "any": [
      { "condition": "fieldEquals", "args": { "field": "accountType", "value": "business" } },
      { "condition": "fieldEquals", "args": { "field": "accountType", "value": "enterprise" } }
    ] }
  ]
}
and: with a negated leaf
{
  "target": "betaFeatureFlag",
  "when": [
    { "all": [
      { "condition": "fieldPresent", "args": { "field": "betaOptIn" } },
      { "not": { "condition": "fieldEquals", "args": { "field": "plan", "value": "legacy" } } }
    ] }
  ]
}

negate: true works on a leaf or a group, so a not wrapper and a negated condition mean the same thing at whichever level you write them. An empty any is vacuously false (nothing to satisfy); an empty all is vacuously true, matching a field with no when at all. Check validates condition names inside nested groups the same way it does for a flat list.

Build schemas in Go

Prefer code over hand-written JSON? The fluent builder gives you editor autocomplete and compile-time checking of rule and field names, then emits an ordinary schema. Every built-in has a typed helper (Email(), Min(n), IsIP()); Rule, Op, and When are escape hatches for custom ones. New() runs Check, so a mistyped rule fails at that call, not at runtime.

builder.go
s, err := schematics.NewSchema().
    Field("email").Required().Type("string").Email().
    Field("age").Type("integer").Min(18).Max(120).
    Field("name").MinLength(2).Trim().Capitalize().
    New(schematics.WithTypeChecks())
if err != nil {
    log.Fatal(err) // New runs Check, so a mistyped rule fails right here
}

Enforce a field's type

By default type is documentation. Turn on WithTypeChecks() and each field's declared type is enforced before its own validators run. Recognized types: string, number, integer, boolean, array, date, object.

schema.json
{ "target": "age", "type": "integer" }
main.go
s := schematics.New(schematics.WithTypeChecks())
_ = s.LoadFile("schema.json")

// "age" is enforced as an integer before its own validators run
err := s.Validate(map[string]any{"age": "not a number"})
// err -> ValidationErrors: target "age" failed rule "isInteger"

Collect every error

By default the first failing rule on a field stops evaluation. WithCollectAll() reports every failure instead, which is what forms usually want.

main.go
s := schematics.New(schematics.WithCollectAll())
_ = s.LoadFile("password.json") // minLength + hasUpper + hasDigit on "pw"

err := s.Validate(map[string]any{"pw": "ab"})
// three errors, not one: minLength, hasUpper, hasDigit

Load and validate from bytes

ImportSchema folds New and LoadBytes into one call. ValidateBytes parses raw JSON and validates it in one step. Pass isArray explicitly so a payload of the wrong shape fails with a clear parse error instead of being guessed.

main.go
s, err := schematics.ImportSchema(schemaBytes) // New + LoadBytes in one call
if err != nil {
    log.Fatal(err)
}

// isArray says whether dataBytes is one object (false) or an array (true),
// so a mismatched payload fails with a clear parse error instead of guessing.
if err := s.ValidateBytes(dataBytes, false); err != nil {
    // *ValidationErrors, *SchemaError, or a JSON parse error
}

Errors, and codes that outlive them

Every failure is a *ValidationError, collected into a *ValidationErrors. Format tokens for Strings: %message, %target, %pointer, %rule (alias %validator), %code, %value, %id.

main.go
var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
    ve.Strings("en", "%target: %message") // []string, formatted
    ve.Messages("ar")                     // []string, localized messages only
    ve.ForTarget("user.profile.name")     // []*ValidationError

    for _, e := range ve.Errors {
        _ = e.Target      // "user.profile.name"
        _ = e.Pointer()   // "/user/profile/name"
        _ = e.Rule        // "minLength"
        _ = e.Code        // "rule.minLength"
        _ = e.Value       // the offending value
        _ = e.RowID       // array row id, if any
        _ = e.Message("ar")
    }
}

Stable error codes

Messages are for people and change freely: reworded, translated, replaced by a catalog. Codes are for machines and do not. Branch on e.Code instead of matching message text.

main.go
for _, e := range ve.Errors {
    switch e.Code {
    case schematics.CodeRequired: // "field.required"
        markMissing(e.Target)
    case schematics.RuleCode("email"): // "rule.email"
        suggestFix(e.Value)
    }
    _ = e.Code.Namespace() // "rule", "field", "request", "schema"
}

Schema problems are coded too, so a test can assert why a schema was rejected without depending on its wording.

schema_test.go
var se *schematics.SchemaError
if errors.As(s.Check(), &se) {
    se.Codes() // []Code{"schema.unknownRule"}
    se.Details // []SchemaProblem{{Code, Target, Message}}
}

The full catalog, and the stability guarantee that comes with it, lives in CODES.md in the repository.

JSON Pointer and problem+json

Every failure knows where it happened as an RFC 6901 JSON Pointer, relative to the document you passed in. Failures that are not about a location in the data, like an unmatched route or an oversized body, return the empty pointer: the document as a whole.

main.go
e.Target    // "user.profile.email"  the schema's flattened key
e.Pointer() // "/user/profile/email" a location in the JSON document

// Inside an array the pointer addresses the row by index, while RowID keeps
// whatever arrayIdKey identified it as. They answer different questions.
e.RowID     // "ORD-2"
e.Pointer() // "/1/email"

RFC 7807 responses

WriteProblem turns any error from Validate, ValidateRequest, or ValidateStream into an application/problem+json response.

handler.go
func handler(w http.ResponseWriter, r *http.Request) {
    if err := api.ValidateRequest(r); err != nil {
        _ = api.WriteProblem(w, r, err) // sets status + Content-Type
        return
    }
    // ...
}
422 response
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "the request payload failed 1 validation rule",
  "instance": "/orders",
  "errors": [
    {
      "pointer": "/customer/email",
      "target": "customer.email",
      "code": "rule.email",
      "rule": "email",
      "detail": "not a valid email address"
    }
  ]
}

The status comes from what actually went wrong: 422 for a payload that failed its rules, 404 for an unmatched route, 413 for a body over the cap, 500 for an unsound schema. That is a property of the failure, not of the handler. Override it and everything else with options, or use NewProblem to get the *Problem back without writing it.

handler.go
schematics.WriteProblem(w, r, err,
    schematics.WithProblemType("https://example.com/probs/validation"),
    schematics.WithProblemTitle("Validation failed"),
    schematics.WithProblemStatus(http.StatusBadRequest),
    schematics.WithProblemLocale("fr"),
)

Message catalogs

Hand-writing messages on every rule in every schema does not scale past a couple of locales. A Catalog loads external bundles keyed by locale, then by rule name, so translators maintain one file per locale.

messages.json
{
  "en": {
    "required":  "{target} is required.",
    "minLength": "{target} must be at least {min} characters"
  },
  "ar": { "required": "{target} مطلوب." }
}
main.go
cat, err := schematics.LoadCatalogFile("messages.json") // or LoadCatalogTOMLFile
if err != nil {
    log.Fatal(err)
}
s := schematics.New(schematics.WithCatalog(cat))

A [locale] table with rule = "template" entries works the same way through LoadCatalogTOMLFile. It is a small hand-rolled subset (no arrays, tables-of-tables, or multiline strings), kept dependency-free on purpose.

Templates interpolate named placeholders, filled from the rule's own args (so minLength can use its min) plus the target, the value, and the row id, along with a practical subset of ICU pluralization.

messages.json
{ "minItems": "{min, plural, one {at least # item} other {at least # items}} required" }

# is replaced with the formatted number; branches match an exact =N selector first, then one or other. There is no select, no nested plurals, and no CLDR plural categories beyond one and other.

Resolution order for Message(locale): an explicit per-rule schema message wins first, then the catalog template, then the rule's own generic message, then a generated description. The catalog only fills gaps. Locale fallback tries ar-EG, then ar, then the catalog's default locale, then en.

Observability

One optional hook underlies all of it. With no observer attached the engine never reads the clock and never builds an event: the cost is a nil check.

main.go
s := schematics.New(schematics.WithObserver(
    schematics.ObserverFunc(func(e schematics.Event) {
        switch e.Kind {
        case schematics.EventRule: // one per rule evaluated
            if e.Failed {
                log.Printf("%s rejected %s (%s)", e.Rule, e.Target, e.Code)
            }
        case schematics.EventValidation: // one per Validate call, or per streamed row
            log.Printf("%s: %d rows, %d errors in %s", e.Op, e.Rows, e.Errors, e.Duration)
        }
    })))

Event.Op is validate, validate_array, validate_stream, or validate_request. A request is reported as one validation covering headers, query, and body rather than three. Rule events fire whether the rule passed or failed, because a failure rate needs a denominator. Timings live on validation events, not rule events, since reading the clock twice per rule would cost more than most rules do.

slog

Failing rules and validation summaries are logged; passing rules are not, because a line per passing rule is noise at any real volume and the count belongs in a metric.

main.go
s := schematics.New(schematics.WithObserver(
    schematics.NewSlogObserver(slog.Default(), slog.LevelDebug)))

Prometheus and OpenTelemetry

The adapters take functions rather than interfaces, so nothing in the library imports a metrics or tracing SDK. Wire Metrics to your own counters, or pass a small tracer adapter to WithTracer and validation runs inside a span whose context reaches every rule.

metrics.go
m := schematics.Metrics{
    Validations: func(op, outcome string, d time.Duration) {
        validations.WithLabelValues(op, outcome).Inc()
        seconds.WithLabelValues(op).Observe(d.Seconds())
    },
    RuleFailures: func(rule, code, _ string) {
        failures.WithLabelValues(rule, code).Inc()
    },
}
s := schematics.New(schematics.WithObserver(m.Observer()))

Cardinality. rule and code are bounded by your schema and are safe as labels. target is not: a wildcard target like items.*.sku expands per array index, one new time series for every index your system ever sees. Drop it unless your targets are entirely literal.

Streaming very large arrays

Validate needs the whole array in memory. For an export that does not fit, or that arrives over a network and has no reason to be buffered, validate it as it decodes. Peak memory is set by how many rows are in flight, not by how many rows there are.

main.go
// Reads a JSON array of objects one row at a time. Same *ValidationErrors,
// same errors, same order that Validate would have returned.
err := s.ValidateStream(resp.Body)

To act on failures as they arrive rather than collecting them all, use ValidateStreamEach. The callback runs once per row, in row order, from a single goroutine, so it does not need to be safe for concurrent use. Returning an error stops the walk and comes back to the caller, which is how you implement a failure budget.

main.go
var bad int
err := s.ValidateStreamEach(ctx, r, func(res schematics.RowResult) error {
    if res.Valid() {
        return nil
    }
    if bad++; bad > 100 {
        return fmt.Errorf("too many invalid rows, stopping at %d", res.Index)
    }
    log.Printf("row %s: %v", res.RowID, res.Errors)
    return nil
})

Rows are validated on goroutines the package owns, where your recover cannot reach. A panic escaping a custom rule, condition, or observer is captured and re-raised on your goroutine as a *StreamPanic carrying the original value, the stack from where it happened, and any error it outranked.

Catch target typos before they ship

A schema is data, so a typo in a target does not fail to compile. A typo in the JSON key (tagret for target) is caught by Check, which reports an empty target. A typo in the target's value ("mane" for "name") is a perfectly valid string, so it silently matches nothing. ValidateSchema closes that gap by matching every target against a sample of real data. Run it in a unit test so schema drift fails CI instead of shipping.

schema_test.go
s := schematics.New()
_ = s.LoadFile("schema.json") // a field targets "mane", a typo for "name"

sample := map[string]any{"name": "Ada", "email": "ada@example.com"}
if err := s.ValidateSchema(sample); err != nil {
    log.Fatal(err)
    // SchemaError: target "mane" does not match anything in the sample data
}

Check schemas from the command line

cmd/schematics wraps Check, Validate, and ValidateSchema so schemas and data can be checked in CI, a Makefile, or a pre-commit hook without writing any Go.

terminal
go install github.com/ashbeelghouri/json-schematics-v2/cmd/schematics@latest
terminal
# validate data against a schema; --array for a JSON array of objects
schematics validate --schema examples/person.schema.json examples/person.data.json

# make sure a schema only references known rules, operators, and conditions
schematics check examples/person.schema.json

# catch a target that typo'd or drifted from real data
schematics lint --sample examples/person.data.json examples/person.schema.json

Every subcommand accepts --json for machine-readable output, and exit codes a pipeline can branch on: 0 valid, 1 problems found, 2 misuse (bad flags, missing file, unparsable JSON).

.github/workflows/ci.yml
- run: go install github.com/ashbeelghouri/json-schematics-v2/cmd/schematics@latest
- run: schematics validate --schema api.schema.json --json testdata/request.json

Production notes

Concurrency

A *Schematics (and an *API) is safe to share across goroutines once the schema is loaded and any custom rules are registered. Build one at start-up and reuse it for every request. Do the loading and rule registration during setup, before the value is shared.

server.go
// Build one validator at start-up...
var validator = schematics.New()

func init() {
    _ = validator.LoadFile("schema.json")
}

// ...then share it across every request goroutine. It takes read locks and
// reuses a cached compiled matcher for each wildcard and regex target.
func handler(w http.ResponseWriter, r *http.Request) {
    if err := validator.ValidateCtx(r.Context(), decode(r)); err != nil {
        // ...
    }
}

Bound request bodies

Cap how many bytes the API layer reads from a request body so a hostile client cannot exhaust memory.

main.go
api := schematics.NewAPI(schematics.WithMaxBodyBytes(1 << 20)) // 1 MiB cap
_ = api.LoadFile("api.schema.json")
// oversized bodies fail with a bodyTooLarge error instead of buffering

A note on separators

Flattening joins keys with the separator (. by default). If your documents can contain object keys that include the separator, choose one that cannot occur in your keys with WithSeparator. Otherwise the flatten and deflate round-trip is ambiguous for those keys.

Try it in the playgroundSee every rule