Schema-first validation for Go. No struct tags. No reflection in the hot path. Same schema drives validation and OpenAPI.
Most Go validation libraries bind rules to structs via string tags (validate:"required,email"). This means:
- Typos compile fine and break silently at runtime.
- Renaming a field doesn't trigger a compile error in your validation rules.
- Reflection runs on every call, showing up in production profiles.
- Custom rules require global string-keyed registration.
- OpenAPI generation needs a separate library with separate annotations.
rulex takes a different approach — rules are typed values, fields are accessed via plain Go functions, and the same schema produces both validation and OpenAPI output.
import (
"github.com/devsnb/rulex"
"github.com/devsnb/rulex/rules"
)
type User struct {
Name string
Email string
Age int
}
var userSchema = rulex.For[User](
rulex.Field("name", func(u User) string { return u.Name }, rules.LenBetween(3, 50)),
rulex.Field("email", func(u User) string { return u.Email }, rules.Email()),
rulex.Field("age", func(u User) int { return u.Age }, rules.Min(18)),
)
res := userSchema.Validate(ctx, user)
if !res.OK() {
for _, iss := range res.Issues {
fmt.Printf("%s: %s\n", iss.Path, iss.Message)
}
}Schemas are immutable and safe for concurrent reuse.
go get github.com/devsnb/rulexRequires: Go 1.24+ Dependencies: None (stdlib only)
Schema[T] — an immutable, reusable validator for type T. Built with rulex.For[T](...).
FieldSpec[T] — binds a field name, a picker function, and rules together. Built with rulex.Field, rulex.Nested, rulex.Whole, or rulex.WholeRule.
Rule[T] — the atom of validation. Knows how to check a value and describe itself (code, message template, params). Rules compose via All, Any, Not, If, IfElse, and IfSet.
Result — the outcome. res.OK() for success; res.Issues for field-level failures; res.Err() for infrastructure errors.
go-playground/validator |
rulex |
|
|---|---|---|
| Binding | validate:"email" string tag |
func(u User) string { return u.Email } |
| Refactor safety | Breaks silently on rename | Compiler error at the call site |
| Reflection | Every call | Only at schema construction |
| Custom rules | Register globally by name | Just a function |
| OpenAPI | Separate library + annotations | Built in, same schema |
String: NonEmpty, MinLen, MaxLen, LenBetween, Matches, Email, HasPrefix
Numeric (any cmp.Ordered): Min, Max, Between, GT, LT
Time: Before, After, BetweenTime, Past, Future, WithinDuration, NonZeroTime (all with *Func variants for injectable clocks)
Duration: DurationMin, DurationMax, DurationBetween, PositiveDuration, NonNegativeDuration
Boolean: IsTrue, IsFalse
Collection: Each, Keys, Values, Unique
Misc: OneOf, NotOneOf, NonZero
Combinators: All, Any, Not, If, IfElse, IfSet
rulex.Field("bio", func(u User) string { return u.Bio },
rules.MaxLen(500),
).Optional()When a field is marked .Optional(), rules are skipped entirely if the picked value is the zero value for its type. The field is also omitted from the required array in OpenAPI output.
var addressSchema = rulex.For[Address](
rulex.Field("city", func(a Address) string { return a.City }, rules.NonEmpty()),
rulex.Field("zip", func(a Address) string { return a.Zip }, rules.Matches(`^\d{5}$`)),
)
var userSchema = rulex.For[User](
rulex.Nested("address", func(u User) Address { return u.Address }, addressSchema),
)Issue paths auto-prefix: address.city, address.zip.
When validation depends on multiple fields, use Whole:
rulex.Whole(func(_ context.Context, cur *rulex.Cursor, u User) error {
if u.Password != u.ConfirmPassword {
cur.Report(rulex.Issue{
Path: rulex.At("confirmPassword"),
Code: "password_mismatch",
Message: "passwords must match",
})
}
return nil
}),For cross-field rules that should appear in Plan() / OpenAPI output, implement Rule[T] and pass it to WholeRule.
rulex.Field("tags", func(u User) []string { return u.Tags },
rules.Each[string](rules.NonEmpty()),
rules.Unique[string](),
)Paths report as tags[0], tags[1], etc. Map rules use Keys and Values — paths report as metadata["x-custom"].
When a rule does I/O (database, external API), use CheckAsync. The engine automatically runs async rules concurrently:
rulex.Field("email", func(u User) string { return u.Email },
rules.Email(),
rules.CheckAsync[string]("unique_email", "email already registered",
func(ctx context.Context, email string) error {
exists, err := repo.EmailExists(ctx, email)
if err != nil {
return rulex.Infra(err) // DB down — NOT a validation error
}
if exists {
return errors.New("taken")
}
return nil
},
),
),rulex.Infra(err) wraps infrastructure errors (DB outage, upstream 5xx, timeout) so they surface on res.Err() instead of becoming user-facing validation messages. The engine cancels sibling async rules on the first infra error.
Important: Always check
res.Err()before usingres.Issues. A non-nilErr()means issues may be incomplete.
Three options, in order of complexity:
-
rules.Check— for simple sync predicates:func StartsWith(prefix string) rulex.Rule[string] { return rules.Check[string]("starts_with", "must start with {{.prefix}}", func(_ context.Context, v string) error { if !strings.HasPrefix(v, prefix) { return errors.New("bad prefix") } return nil }, ).WithParam("prefix", prefix) }
-
rules.CheckAsync— same shape, but marks the rule for concurrent execution. -
Implement
Rule[T]directly — when one check needs to report multiple issues (e.g. password strength: too short AND no digit AND no symbol):func (r *passwordRule) Check(_ context.Context, cur *rulex.Cursor, v string) error { base := cur.Path() if len(v) < r.policy.MinLen { cur.Report(rulex.Issue{Path: base.Clone(), Code: "pw_too_short", Message: "..."}) } // ... more checks ... return nil // reported via cursor; nil means "don't auto-add an issue" }
Same schema, zero extra annotations:
import "github.com/devsnb/rulex/openapi"
schema := openapi.FromPlan(userSchema.Plan())
json.NewEncoder(os.Stdout).Encode(schema){
"type": "object",
"required": ["name", "email", "age"],
"properties": {
"name": { "type": "string", "minLength": 3, "maxLength": 50 },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 18 }
}
}Built-in rules map automatically: len_between → minLength/maxLength, min/max → minimum/maximum, matches → pattern, email → format, one_of → enum, and so on.
For custom rules, register a mutator:
openapi.Register("starts_with", func(p map[string]any, s *openapi.Schema) {
if prefix, ok := p["prefix"].(string); ok {
s.Pattern = "^" + regexp.QuoteMeta(prefix)
}
})Use openapi.WithVendorExtensions() to emit unmapped rules as x-rulex-* entries.
// Stop after the first issue across all fields (default: accumulate all)
res := schema.Validate(ctx, user, rulex.WithStopOnFirst())
// Cap concurrent async rule goroutines (default: GOMAXPROCS)
res := schema.Validate(ctx, user, rulex.WithMaxConcurrency(4))
// Custom message resolver (for i18n or template overrides)
res := schema.Validate(ctx, user, rulex.WithMessageResolver(myResolver))- Schemas are constructed once, typically at package init. Reuse them across requests — they're immutable and concurrency-safe.
- Rules within a field stop at the first failure. Fields accumulate independently. Use
WithStopOnFirst()to stop across fields. - Picker functions must not panic. They run once per validation per field.
- Duplicate field names panic at
For[T]. This is caught at init time. - Issue ordering is not guaranteed when async rules are involved.
- Message templates use
text/templatesyntax:"must be at least {{.min}} characters". Rule params become template variables. WithParam/WithMessagereturn new rules — they don't mutate the receiver. Chain them or assign the result.
Runnable examples live in examples/:
go run ./examples/signup # Registration with nested schemas, async, Infra errors
go run ./examples/custom-rules # All three rule-authoring patterns
go run ./examples/openapi # JSON Schema output with vendor extensionsSee the Quickstart Guide for a complete walkthrough of all features.