Getting started

Set up, validate, and transform.

Everything you need to go from install to a validated document, then extend the library with your own rules.

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

Localization

Attach a messages map to any rule for per-locale strings, then render them with ve.Strings("ar", ...). See the localized errors example.

Try it in the playgroundSee every rule