Validation
A typed, fluent builder. Build a valid.Validator from the context, chain type-specific rules per field, and return v.Error().
Not just for HTTP: job parameters validate the same way — a job's run context is a
core.IContext, so everything on this page (including the DB rules) works inside a job. See jobs.md. Machine codes are separate from human messages, and DB rules never swallow errors.
Basic usage
import "gitlab.finema.co/finema/idin-core/v2/valid"
func (r *CreateUserRequest) Valid(ctx core.IContext) core.IError {
v := valid.New(ctx)
v.Str("email", r.Email).Required().Email().Unique("users", "email")
v.Str("username", r.Username).Required().Length(3, 20)
v.Int("age", r.Age).Required().Between(18, 150)
v.Arr("tags", r.Tags).Max(5)
return v.Error()
}Fields are pointers (from JSON binding). Empty/nil values are skipped by every rule except Required, so optional fields validate only when present.
Rules
String (v.Str): Required, Email, URL, Length(min,max), Min, Max, In(...), Lowercase, Uppercase, Contains, NotContains, Prefix, Suffix, Match(re), Numeric, UUID, IP, Base64, JSON, Date(layout...), DateTime, ISO8601, Custom(code, ok), plus the DB rules below.
Number (v.Int, v.Float): Required, Min, Max, Between, In(...), Positive.
Bool (v.Bool): Required.
Time (v.Time, for *time.Time): Required, Before(t), After(t), Between(start, end).
Array (v.Arr): Required, Size(n), Min(n), Max(n).
Rules stop on the first failure per field (no piled-up messages).
Adding rules to the same field on separate lines
v.Str(...) (and v.Int/v.Bool/v.Arr) return a field builder (a pointer). Keep it in a variable to continue its rules on later lines — the same builder is reused, so stop-on-first still applies (one violation per field):
email := v.Str("email", r.Email)
email.Required()
email.Email()
email.Min(3)
// empty email → only REQUIRED (later rules skip, exactly like one chain)This is the clean way to split a long chain, or to add a rule conditionally:
name := v.Str("name", r.Name)
name.Required().Length(2, 50)
if isCreate {
name.Unique("users", "name") // continues the SAME builder
}⚠️ Don't call
v.Str("email", …)twice for the same field — each call makes an independent builder (stop-on-first won't carry over, and the JSON output keeps only the last violation for that field). Store the builder in a variable instead, or use one chain.
For conditional blocks across several fields, prefer When.
Nested / arrays of objects
v.Each("items", len(r.Items), func(iv *valid.Validator, i int) {
iv.Str("name", r.Items[i].Name).Required()
iv.Int("qty", r.Items[i].Qty).Required().Min(1)
})
// failed fields are prefixed: items.0.name, items.1.qty, ...DB rules
Unique/Exists run a query. If the query itself fails (bad column, connection lost) the error is surfaced as HTTP 500, not silently passed — v1 swallowed these, letting duplicates through.
v.Str("email", r.Email).Unique("users", "email")
v.Str("code", r.Code).Exists("coupons", "code")Complex conditions (Scopes)
Unique/Exists take any number of Scopes — func(*gorm.DB) *gorm.DB — so you can express tenant scoping, soft-delete filters, or exclude the current row:
// unique email WITHIN a tenant, excluding the record being updated
v.Str("email", r.Email).Unique("users", "email",
valid.Cond("tenant_id = ?", tenantID),
valid.Except("id", userID), // ignore self on update
)
// exists only among active (non-deleted) rows
v.Str("code", r.Code).Exists("coupons", "code",
valid.Cond("deleted_at IS NULL"),
valid.Cond("expires_at > ?", time.Now()),
)
// or an inline scope for anything
v.Str("email", r.Email).Unique("users", "email", func(db *gorm.DB) *gorm.DB {
return db.Where("status <> ?", "banned")
})Helpers: valid.Except(column, value) (adds column != value) and valid.Cond(query, args...) (adds a WHERE). Compose as many as you need.
Mongo
v.Str("email", r.Email).MongoUnique("users", bson.M{"email": *r.Email})
v.Str("ref", r.Ref).MongoExists("orders", bson.M{"ref": *r.Ref})Fully custom (Check / CheckDB)
For logic the built-ins don't cover, Check runs a predicate with the context (DB, cache, repositories). A returned error is treated as infra failure (500); ok=false records a violation with your code:
v.Str("coupon", r.Coupon).Check("COUPON_INVALID", func(ctx core.IContext, code string) (bool, error) {
n, err := repository.New[Coupon](ctx).Where("code = ? AND used = false", code).Count()
return n > 0, err
})For a check not tied to a single string value, use the validator-level CheckDB(field, code, fn):
v.CheckDB("slug", "SLUG_TAKEN", func(ctx core.IContext) (bool, error) {
n, err := repository.New[Page](ctx).Where("slug = ? AND site_id = ?", slug, siteID).Count()
return n == 0, err
})Error shape
v.Error() is a core.IError rendering:
{
"code": "INVALID_PARAMS",
"message": "Invalid parameters",
"fields": {
"email": { "code": "INVALID_EMAIL", "message": "The email field must be a valid email address" }
}
}Codes are stable and machine-readable; messages are generated from a catalog of templates (see below).
Custom messages
Every rule sets a Code; the human Message is filled in automatically from a template. There are three ways to control it:
1. Default catalog (valid/messages.go) — templates keyed by code, with {field} and rule params ({min}, {max}, {sub}, …) interpolated:
"REQUIRED": "The {field} field is required",
"INVALID_NUMBER_BETWEEN": "The {field} field must be between {min} and {max}",So v.Str("email", r.Email).Required() → "The email field is required" with no extra work.
2. Override a code globally (at init — e.g. to localise or reword). Affects every field using that code:
func init() {
valid.SetMessage("REQUIRED", "{field} จำเป็นต้องกรอก")
valid.SetMessage("INVALID_EMAIL", "รูปแบบอีเมลไม่ถูกต้อง")
}3. Per-rule inline with .Message(...) — override the message of the rule immediately before it, applied only when that rule is the one that failed. So you can give a different message to each rule on the same field:
v.Str("password", r.Password).
Required().Message("Password is required").
Min(8).Message("Password must be at least 8 characters")
// empty password → "Password is required"
// "abc" → "Password must be at least 8 characters"v.Int("age", r.Age).
Required().Message("อายุจำเป็นต้องกรอก").
Between(18, 150).Message("อายุต้องอยู่ระหว่าง 18–150 ปี").Message() binds to the preceding rule only:
- it's a no-op when that rule passed or was skipped, and when the field is valid;
- it overrides the human message while keeping the machine
Code; - stop-on-first still means one violation per field (the first failing rule's, with the message you attached to it).
For
Must(field, code, ok)(cross-field), the message comes from the catalog forcode— register it withSetMessage, or reuse an existing code.
i18n: call
SetMessageper locale at startup (or swap the catalog). Codes stay constant so clients can also translate on their side bycode.
Conditional & cross-field validation
For rules that depend on other fields, use When (conditional blocks) and Must (arbitrary predicate → violation on a field).
Conditional required — validate a field only when another says so:
func (r *NotifyRequest) Valid(ctx core.IContext) core.IError {
v := valid.New(ctx)
v.Bool("notify", r.Notify).Required()
// notify_email is required (and must be an email) only when notify == true
v.When(r.Notify != nil && *r.Notify, func(v *valid.Validator) {
v.Str("notify_email", r.NotifyEmail).Required().Email()
})
return v.Error()
}Discriminated union — different rules per type:
v.Str("type", r.Type).Required().In("person", "company")
if r.Type != nil {
switch *r.Type {
case "person":
v.Str("first_name", r.FirstName).Required()
v.Str("last_name", r.LastName).Required()
case "company":
v.Str("company_name", r.CompanyName).Required()
v.Str("tax_id", r.TaxID).Required().Length(13, 13)
}
}Cross-field — compare two fields with Must(field, code, ok, data...):
// end must be after start
v.Must("end_at", "INVALID_DATE_RANGE",
r.StartAt == nil || r.EndAt == nil || r.EndAt.After(*r.StartAt),
map[string]any{"other": "start_at"})
// password confirmation
v.Must("password_confirm", "MISMATCH",
r.Password != nil && r.PasswordConfirm != nil && *r.Password == *r.PasswordConfirm)Exactly one of — mutually exclusive fields:
hasEmail := r.Email != nil && *r.Email != ""
hasPhone := r.Phone != nil && *r.Phone != ""
v.Must("contact", "REQUIRED_ONE_OF", hasEmail != hasPhone) // XOR: exactly oneCustom codes get a generic message by default; register your own at init time:
func init() {
valid.SetMessage("INVALID_DATE_RANGE", "The {field} field must be after {other}")
valid.SetMessage("MISMATCH", "The {field} field does not match")
}Reusable nested validators
Define a request piece once as an IValidatable (a Validate(v *valid.Validator) method) and reuse it — standalone, nested under a field, or over a slice.
type AddressRequest struct {
Street *string `json:"street"`
Zip *string `json:"zip"`
}
// rules live here, once
func (r *AddressRequest) Validate(v *valid.Validator) {
v.Str("street", r.Street).Required()
v.Str("zip", r.Zip).Required().Length(5, 5)
}
// standalone request: Valid delegates to the shared rules
func (r *AddressRequest) Valid(ctx core.IContext) core.IError {
return valid.Run(ctx, r)
}Nest under a field — Nested prefixes every violation with name.:
type CheckoutRequest struct {
BillingAddress *AddressRequest `json:"billing_address"`
ShippingAddress *AddressRequest `json:"shipping_address"`
}
func (r *CheckoutRequest) Valid(ctx core.IContext) core.IError {
v := valid.New(ctx)
v.Nested("billing_address", r.BillingAddress) // → billing_address.zip, ...
v.Nested("shipping_address", r.ShippingAddress) // → shipping_address.street, ...
return v.Error()
}Slice of nested objects — EachNested prefixes with name.<index>.:
type OrderRequest struct {
Items []*ItemRequest `json:"items"`
}
func (r *OrderRequest) Valid(ctx core.IContext) core.IError {
v := valid.New(ctx)
v.Int("total", r.Total).Required().Min(0)
valid.EachNested(v, "items", r.Items) // → items.0.name, items.1.qty, ...
return v.Error()
}The same AddressRequest/ItemRequest type is now reused everywhere with one source of truth for its rules. Inside Validate, v.Ctx() gives access to the context for DB rules (Unique/Exists).