A typed, reflection-free, tag-free validation and sanitization library for Go.
- No struct tags
- No reflection
- No global registry — type packages export a mutable
IDsmap for app-level extensions - Stdlib only
- Generic: works with any type
go get github.com/rah-0/ward
See examples/loginform for a complete working example covering basic validation, structured error responses, and field-level failure inspection.
The core pattern for concurrent use (e.g. an HTTP handler):
func handle(r *http.Request) {
var form LoginForm
// populate form from request...
v := ward.New().Add(
strs.New("Email", &form.Email, strs.RuleNotEmpty(), strs.RuleIsEmail()),
strs.New("Password", &form.Password, strs.RuleNotEmpty(), strs.RuleLengthMin(8)),
).Run()
if v.HasFailures() {
// inspect v.Failures()
}
}Validate, Field, and the form struct must all be per-request. Sharing any of them across goroutines is a data race — Field holds a *T to the source value, and Validate accumulates results in a mutable slice.
Sanitizers are rules that mutate the value in place. They run in the same rule chain and write back to the source pointer directly.
name := " alice "
v := ward.New().Add(
strs.New("Name", &name, strs.RuleTrim(), strs.RuleNotEmpty()),
).Run()
fmt.Println(name) // "alice" — source updated in place
_ = v.HasFailures()Sanitizers write back through the pointer, so the source variable reflects the sanitized value immediately after Run(). Callers that need to preserve the original should copy it before calling Run().
ward.As[T] maps failures to any type, making it straightforward to produce a JSON-serialisable response:
type ValidationError struct {
Field string `json:"field"`
Rule uint32 `json:"rule"`
Arg1 any `json:"arg1,omitempty"`
Arg2 any `json:"arg2,omitempty"`
}
errs := ward.As(v.Failures(), func(r *ward.Result) ValidationError {
return ValidationError{
Field: r.FieldName,
Rule: r.RuleID,
Arg1: r.Arg1,
Arg2: r.Arg2,
}
})
// json.Marshal(errs) →
// [{"field":"Password","rule":3,"arg1":8},{"field":"Email","rule":10}]As never touches the original []*Result slice — it projects into whatever shape your API layer needs.
Parametrized rules carry their values back in Arg1 and Arg2. The frontend receives the exact constraint the backend enforced — no need to duplicate ID's and configuration in client code.
// backend: strs.RuleLengthMin(8) fails → Result{RuleID: 3, Arg1: 8}
// frontend receives: {"field":"Password","rule":3,"arg1":8}
// frontend renders: "Password must be at least 8 characters"
Similarly, RuleLengthBetween(5, 50) returns Arg1=5, Arg2=50, and RuleContains("@") returns Arg1="@".
Every type package exports an IDs map (map[uint32]string) associating each rule ID with its name, and a TypeID constant identifying the package. These can be served from a single endpoint so the frontend always knows what validations exist:
// GET /api/validation-rules
func GetValidationRules(w http.ResponseWriter, r *http.Request) {
rules := map[uint32]map[uint32]string{
strs.TypeID: strs.IDs,
// add further type packages here as the API grows
}
json.NewEncoder(w).Encode(rules)
}
// response:
// {"2":{"2":"NotEmpty","3":"LengthMin","4":"LengthMax",...}}When a failure arrives at the frontend with TypeID=2, RuleID=3, it looks up TypeID 2 → strs, RuleID 3 → LengthMin, and can display the right message using Arg1 as the actual minimum value. The frontend never hardcodes validation logic — it derives everything from what the backend exposes.
Applications can register custom rules in two ways:
Automatic ID assignment — IDsAdd picks the next available ID, avoiding conflicts with built-in or future rules:
idPasswordsMatch := strs.IDsAdd("PasswordsMatch")
idUsernameAvailable := strs.IDsAdd("UsernameAvailable")Manual ID assignment — write directly to the map when you need a specific ID:
strs.IDs[1000] = "PasswordsMatch"
strs.IDs[1001] = "UsernameAvailable"Stop at the first failing field across the whole validator:
v.Policy.StopOnFail = trueStop at the first failing rule within a single field:
fieldEmail.Policy.StopOnFail = trueward.Rule[T] and ward.Field[T] are generic over any type T — a struct, a primitive, a type alias. Implementing a custom type package requires only a TypeID, two type aliases, and a New() function.
New() stamps TypeID on every rule automatically — rule constructors only need ID and Fn.
See examples/ for the full implementation guide and the following working examples:
| Example | T | Demonstrates |
|---|---|---|
| loginform | string |
Basic usage, As[T], structured error responses |
| phonenumber | struct |
Multi-field struct, parametrized rules |
| percentage | float64 |
Primitive type, numeric range rules |
Full comparison against go-playground/validator and ozzo-validation:
github.com/rah-0/benchmarks/tree/master/validator
Enjoying ward? If it saved you time or brought value to your project, feel free to show some support. Every bit is appreciated 🙂
