Skip to content

devsnb/rulex

Repository files navigation

rulex

Schema-first validation for Go. No struct tags. No reflection in the hot path. Same schema drives validation and OpenAPI.

Why rulex?

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.

Install

go get github.com/devsnb/rulex

Requires: Go 1.24+ Dependencies: None (stdlib only)

Core concepts

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.

Why selectors instead of struct tags

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

Built-in rules

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

Key patterns

Optional fields

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.

Nested schemas

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.

Cross-field rules

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.

Collections

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"].

Async rules and infrastructure errors

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 using res.Issues. A non-nil Err() means issues may be incomplete.

Writing custom rules

Three options, in order of complexity:

  1. 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)
    }
  2. rules.CheckAsync — same shape, but marks the rule for concurrent execution.

  3. 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"
    }

OpenAPI generation

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_betweenminLength/maxLength, min/maxminimum/maximum, matchespattern, emailformat, one_ofenum, 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.

Validate options

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

Things to know

  • 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/template syntax: "must be at least {{.min}} characters". Rule params become template variables.
  • WithParam / WithMessage return new rules — they don't mutate the receiver. Chain them or assign the result.

Examples

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 extensions

Documentation

See the Quickstart Guide for a complete walkthrough of all features.

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages