Skip to content

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

go
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):

go
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:

go
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

go
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.

go
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 Scopesfunc(*gorm.DB) *gorm.DB — so you can express tenant scoping, soft-delete filters, or exclude the current row:

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

go
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:

go
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):

go
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:

json
{
  "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:

go
"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:

go
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:

go
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"
go
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 for code — register it with SetMessage, or reuse an existing code.

i18n: call SetMessage per locale at startup (or swap the catalog). Codes stay constant so clients can also translate on their side by code.

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:

go
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:

go
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...):

go
// 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:

go
hasEmail := r.Email != nil && *r.Email != ""
hasPhone := r.Phone != nil && *r.Phone != ""
v.Must("contact", "REQUIRED_ONE_OF", hasEmail != hasPhone) // XOR: exactly one

Custom codes get a generic message by default; register your own at init time:

go
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.

go
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 fieldNested prefixes every violation with name.:

go
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 objectsEachNested prefixes with name.<index>.:

go
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).

Maintained by Passakon Puttasuwan & Dev Core Team.