Author validation once in Go. Run it everywhere.
Status: early development. The serializable IR (ir package) is the first shipped piece. Validation executors, JSON Schema export, and TypeScript generation follow — see the roadmap below.
validator is a struct-native Go validation library whose rules are stored as a serializable intermediate representation (IR) instead of opaque closures. Because the rules are data, the same tree that validates in Go can be exported to JSON Schema and compiled to a Standard-Schema-compatible TypeScript validator. The backend stays the single source of truth; the frontend validator is generated, never hand-maintained; and any rule that genuinely cannot cross the language boundary is listed in a portability manifest instead of being silently dropped.
New here? Read the user guide: a five-step quickstart, the end-to-end workflow with diagrams, and every feature with a snippet. Every snippet in it is executed by examples/guide_test.go. For the full symbol list there is a generated API reference, and pkg.go.dev renders the doc comments.
Validation rules usually end up as opaque tag strings or Go closures. Neither can be inspected, so neither can be exported, and the frontend copy of every rule is written by hand and drifts. This library takes the other path: struct-native authoring, rules stored as data, JSON Schema export, and multi-language output, together.
Rules are data, never closures. .Min(3) records {"kind":"min","params":{"value":3}}, not func(v) error. That is the whole reason export is possible. The only opaque node is custom, and it is explicitly marked non-portable.
| Path | What lives there |
|---|---|
ir/ |
The IR: type nodes, rules, conditions, portability classes, versioned JSON round-trip, structural Check. |
i18n/ |
Message catalog: export the default with validatorctl catalog, translate, load on both sides. |
verr/ |
Error model: Issue{Code, Path, Message, Rule, Params}. Same codes on the TS side. |
interp/ |
Reflection interpreter: interp.FromSchema(schema) then Validate(ctx, value). |
jsonschema/ |
JSON Schema emitter (2020-12, draft-07) plus the portability manifest of everything the schema cannot carry. |
vb/ |
The typed builder: vb.Struct, vb.F(ref, builder), per-kind builders, rename-safe conditions. Compiles to the IR. |
examples/ |
Proofs against independent implementations: a third-party JSON Schema validator, and the real TS runtime under Node against a live rule server. |
cli/ |
validatorctl — separate module so the library stays dependency-light. |
codegen/ |
validatorctl gen: reflection-free Validate<Type> functions from the IR, conformance-tested against the interpreter. |
tags/ |
validatorctl migrate: existing validate struct tags → IR plus builder source, unsupported tags reported. |
accessors/ |
validatorctl fields: generates <Type>Fields accessors so rules name fields by symbol, not string. |
rulehttp/ |
Serves async rules over HTTP for the frontend round-trip: opt-in, rate-limited, auth hook. |
ts/ |
@ubgo/validator, the TypeScript runtime: Ajv + Standard Schema + manifest-aware round-trips. |
cel/ |
Optional expression engine, its own module so the library keeps zero dependencies. Adds arithmetic and aggregation across fields. |
Requires Go 1.24 or later; CI builds every push on 1.24 and on the current release.
go get github.com/ubgo/validator@latest
go install github.com/ubgo/validator/cli/cmd/validatorctl@latest
# Only if you need expressions (arithmetic or aggregation across fields):
go get github.com/ubgo/validator/cel@latestThe library has no third-party dependencies. The expression engine is a separate module so that stays true for everyone who does not use it.
- IR — shipped: nodes, rules, conditions, JSON round-trip,
Check. - Typed builder — shipped:
vbcompiles to the IR and reproduces the golden fixture byte for byte. - Reflection interpreter — shipped:
interpruns the IR against structs or decoded JSON, refuses anything it cannot execute. - Codegen executor — shipped:
validatorctl genemits reflection-freeValidate<Type>functions; an 86-case conformance suite requires identical issues from both executors. The success path is allocation-free throughValidate<Type>Into. - Codegen accessors — shipped:
validatorctl fieldsgeneratesUserFieldsfor rename-safe rule authoring. - JSON Schema emitter — shipped: draft 2020-12 and draft-07, discriminator-keyed if/then for variants, and a manifest of every non-portable rule.
- TypeScript runtime shim — shipped:
@ubgo/validatorints/, Ajv + Standard Schema + Standard JSON Schema, same issue codes as Go. - Portability manifest — shipped with the emitter: every custom, async, cross-field or transform rule listed with a class and reason.
//go:generate validatorctl fields
// → fields.gen.go: UserFields.Email, UserFields.Age, … as vb.FieldRef[User, T]
var UserSchema = vb.Struct("User",
vb.F(UserFields.Email, vb.String().Email().
Custom("no_temp_domains", noTempDomains). // opaque: runs in Go, reported to the frontend
Async("unique_email", "/v/unique_email", unique)), // server: frontend round-trips
vb.F(UserFields.Age, vb.Int[int32]().Min(18).Max(120)),
).Gate(
vb.Requires(vb.Eq(UserFields.Age, 18), UserFields.Email),
).MustBuild()
doc := UserSchema.IR() // *ir.Schema — serializable, exportable
v, _ := interp.FromSchema(UserSchema)
if err := v.Validate(ctx, &user); err != nil {
issues := err.(*verr.Errors).Issues // [{code:"min", path:"$.age", ...}]
}vb.F(UserFields.Age, vb.String()) is a compile error: a builder's value type must match the field's Go type.
validatorctl catalog > en.json # every issue code with its English template
validatorctl catalog --check de.json # exit 1 if the translation misses a codede := i18n.Merge(i18n.Default(), loaded) // partial translations fall back to English
issues := de.Localize(v.Run(ctx, &user)) // same codes, German messagescreateValidator(schema, { manifest, catalog: de }); // browser renders the same Germanvalidatorctl fields ./models # UserFields accessors
validatorctl migrate ./models --type User --out user.ir.json --builder user_schema.gen.goEvery tag with an IR equivalent becomes a rule or gate; every tag without one is listed, and the command exits non-zero unless you pass --allow-partial. The emitted builder source is yours to edit; the tags can be deleted.
validatorctl ir export ... # or: json.Marshal(UserSchema.IR())
validatorctl gen ./models --schema user.ir.json # → models/validate.gen.goerrs := models.ValidateUser(ctx, &user, UserSchema) // *verr.Errors, same issues as the interpreter
first := &verr.Errors{FailFast: true} // stop at the first issue
models.ValidateUserInto(ctx, &user, UserSchema, first)On the example fixture (arm64; a union, a discriminated union, transforms, two custom rules and a gate predicate), a valid order costs:
| Path | ns/op | allocs/op |
|---|---|---|
ValidateOrderInto (caller-owned issue list) |
490 | 0 |
ValidateOrder |
510 | 1 (the result) |
interp |
9700 | 81 |
Paths are built lazily on the stack and rendered only when an issue is reported. Custom rules and gate predicates reach the author's typed function through rt.CallT, so no value is boxed on the way; the email and URL formats take allocation-free fast paths that accept only a proven subset of what net/mail and net/url accept and defer everything else to them. Run go test -bench . ./codegen/internal/example/.
Struct input is never coerced; a typed field already has its type. On map[string]any input (decoded JSON, form values) the interpreter converts strings for int, float, bool, time and duration fields according to a mode:
interp.FromSchema(UserSchema) // default: only fields marked .Coerce()
interp.FromSchema(UserSchema, interp.Lax()) // every coercible field, except those marked .NoCoerce()
interp.FromSchema(UserSchema, interp.Strict()) // only marked fields (names the intent; same as the default today)Field markers beat the call option in both directions: .Coerce() always converts, .NoCoerce() never does. RFC 3339 strings are also accepted for time.Time fields on map input and written back as time.Time.
res, _ := jsonschema.Emit(UserSchema.IR())
doc, _ := res.JSON() // JSON Schema 2020-12, byte-deterministic
for _, e := range res.Manifest.Entries {
// e.g. {Path:"$.email", Kind:"rule", Name:"async", Class:"server", Endpoint:"/v/unique_email"}
}Or from the CLI: validatorctl ir export user.ir.json --out user.schema.json. The manifest goes to stderr; --json returns schema and manifest in one envelope for CI gates.
The IR itself has a schema. validatorctl ir contract (or jsonschema.Contract()) prints the JSON Schema every serialized IR document conforms to, generated from the Go types so it cannot drift; non-Go tools validate and type IR files against it. ir.Check also lints every pattern rule: it must be valid RE2 and free of RE2-only syntax (inline flags, named groups, \A, \z, \Q, POSIX and Unicode classes) so the same regex runs unchanged in the browser.
import { createValidator } from "@ubgo/validator";
import { userSchema, userManifest } from "./user.schema"; // validatorctl ir export --ts user.schema.ts
const user = createValidator(userSchema, { manifest: userManifest }); // value type inferred from the schema
user.validate(input); // sync: JSON Schema rules, Go-identical issue codes
await user.validateAsync(input); // + debounced round-trips for async rules
useForm({ validators: { onChange: user } }); // any Standard Schema consumerRules the schema cannot carry are reported through the manifest, never dropped: opaque rules warn once and run on the server, async rules round-trip to POST /v/<rule>.
Serving that round-trip in Go is one call, and it runs the same function Validate() runs:
h, _ := rulehttp.FromSchema(UserSchema, rulehttp.WithAuthorize(requireSession))
h.Mount(mux) // POST /v/unique_email → {"ok":false,"issues":[{"code":"custom","message":"already taken"}]}Not protobuf-shaped. Not a Go→TS type generator. Not arbitrary Go→JS translation of closures. Not a forms framework. No init() registration or global side effects.
task ci is the gate: format, vet, lint, race tests for the library and CLI, typecheck plus coverage-thresholded tests plus build for the TypeScript runtime, then the proof module. task build:cross compiles for linux, darwin, windows and freebsd. The same runs in GitHub Actions on every push.
Apache-2.0.