Validation

> Validate HTTP requests and form data with Velocity's declarative rule-based validation system.

Velocity provides a flexible, extensible validation system for validating HTTP requests, form data, and general data structures. The validation package uses a declarative, rule-based approach designed for Go’s type system.

Import path: github.com/velocitykode/velocity/validation

For HTTP handlers, the higher-level form-request helper (vform.Form[T]) wraps the rules below with binding, flashing, and redirect-on-failure. Reach for vform first; reach for validation.Check* directly when you need a custom render path or you’re validating data that isn’t an HTTP request.

Quick Start

Simple and Declarative: Define validation rules as a slice of rule tokens per field and let Velocity handle the rest.
import "github.com/velocitykode/velocity/validation"

func validateUser(data map[string]interface{}) error {
    rules := validation.Rules{
        "email":    {"required", "email"},
        "password": {"required", "min:8"},
        "age":      {"required", "numeric", "min:18"},
        "username": {"required", "alpha_num"},
    }

    result := validation.CheckData(data, rules)
    if result.HasErrors() {
        // result.All() is map[string]string (first error per field).
        // result.Messages() is map[string][]string (all errors per field).
        fmt.Println("Validation failed:", result.All())
        return result.Err()
    }

    // No errors, use the input directly.
    return nil
}
import (
    "github.com/velocitykode/velocity/router"
    "github.com/velocitykode/velocity/validation"
)

func RegisterUser(c *router.Context) error {
    rules := validation.Rules{
        "name":     {"required", "string"},
        "email":    {"required", "email"},
        "password": {"required", "min:8", "confirmed"},
        "age":      {"required", "numeric", "min:18"},
        "terms":    {"required", "accepted"},
    }

    result := validation.Check(c.Request, rules)
    if result.HasErrors() {
        return c.JSON(422, map[string]interface{}{
            "errors": result.Messages(),
        })
    }

    // Validation passed, bind the request body and proceed.
    var input struct {
        Name, Email, Password string
        Age                   int
    }
    if err := c.BindAuto(&input); err != nil {
        return err
    }

    return c.JSON(200, input)
}
import (
    "github.com/velocitykode/velocity/router"
    "github.com/velocitykode/velocity/validation"
)

func validateWithCustomMessages(c *router.Context) error {
    rules := validation.Rules{
        "email":    {"required", "email"},
        "password": {"required", "min:8"},
    }

    messages := validation.Messages{
        "email.required":    "Please provide your email address",
        "email.email":       "Please enter a valid email address",
        "password.required": "Password is required",
        "password.min":      "Password must be at least 8 characters",
    }

    result := validation.Check(c.Request, rules, messages)
    if result.HasErrors() {
        return c.JSON(422, map[string]interface{}{
            "errors": result.Messages(),
        })
    }

    return nil
}

Configuration

The validation package works out of the box with no configuration required. However, you can customize behavior through environment variables:

# Stop validation on first error (default: false)
VALIDATION_STOP_ON_FIRST=false

# Default locale for error messages
VALIDATION_DEFAULT_LOCALE=en

# Bail on first rule failure per field (default: true)
VALIDATION_BAIL_ON_ERROR=true

Available Rules

Velocity ships 49 built-in rules plus two database rules (unique, exists) that are registered automatically when validation runs against an orm.Database (i.e. CheckWithDB / CheckDataWithDB, or via vform.Form[T] when the request has services attached).

The table below is the complete catalog. The “Empty input” column describes how the rule treats a nil or absent field; combine with required to enforce presence and with nullable / filled for opt-out semantics.

RuleArgsExampleEmpty input
accepted(none){"terms": {"accepted"}}fails
alpha(none){"name": {"alpha"}}passes (use with required)
alpha_dash(none){"slug": {"alpha_dash"}}passes
alpha_num(none){"username": {"alpha_num"}}passes
array(none){"tags": {"array"}}passes
betweenmin,max{"age": {"numeric", "between:18,65"}}passes
boolean(none){"active": {"boolean"}}passes
confirmed(none){"password": {"confirmed"}}passes (skipped when value is nil)
date(none){"published_at": {"date"}}passes
date_formatlayout{"day": {"date_format:2006-01-02"}}passes
differentfield{"alt_email": {"different:email"}}passes
email(none){"email": {"email"}}empty string fails; nil passes
ends_withsuffix[,...]{"file": {"ends_with:.pdf,.png"}}passes
existstable[,column]{"team_id": {"exists:teams,id"}}passes (DB rule, requires CheckWithDB)
file(none){"avatar": {"file"}}passes
filled(none){"bio": {"filled"}}passes when field absent; fails when present-but-empty
gtn or field{"max": {"gt:min"}}passes
gten or field{"qty": {"gte:1"}}passes
image(none){"avatar": {"image"}}passes
inv[,v,...]{"role": {"in:admin,user,mod"}}passes
integer(none){"age": {"integer"}}passes
ip(none){"addr": {"ip"}}empty string fails; nil passes
ipv4(none){"addr": {"ipv4"}}empty string fails; nil passes
ipv6(none){"addr": {"ipv6"}}empty string fails; nil passes
json(none){"meta": {"json"}}passes
ltn or field{"min": {"lt:max"}}passes
lten or field{"qty": {"lte:100"}}passes
maxn{"bio": {"max:500"}}passes
mimesext[,ext,...]{"upload": {"mimes:jpg,png,pdf"}}passes
minn{"password": {"min:8"}}passes
not_inv[,v,...]{"username": {"not_in:admin,root"}}passes
nullable(none){"middle_name": {"nullable", "string"}}always passes (marker)
numeric(none){"price": {"numeric"}}passes
password[len][,mixed][,num][,symbol]{"pw": {"password:12"}}fails (treats empty as missing)
present(none){"opt_in": {"present"}}fails when key absent; passes when key present-but-empty
regexpattern{"sku": {"regex:^[A-Z]{3}-\\d{4}$"}}passes (pattern must be ^...$ anchored)
required(none){"name": {"required"}}fails on nil, "", empty slice/map
required_iffield,value{"phone": {"required_if:contact,phone"}}conditional
required_unlessfield,value{"company": {"required_unless:type,personal"}}conditional
required_withfield{"shipping": {"required_with:address"}}conditional
required_withoutfield{"sku": {"required_without:gtin"}}conditional
samefield{"pw_confirm": {"same:password"}}passes
sizen{"zip": {"size:5"}}passes
starts_withprefix[,...]{"url": {"starts_with:https://"}}passes
string(none){"name": {"string"}}passes
timezone(none){"tz": {"timezone"}}empty string fails; nil passes
ulid(none){"id": {"ulid"}}passes
uniquetable[,column[,exceptID[,idCol]]]{"email": {"unique:users,email"}}passes (DB rule, requires CheckWithDB)
url(none){"website": {"url"}}empty string fails; nil passes
url_public(none){"webhook": {"url_public"}}same as url plus rejects private/internal hosts
uuid(none){"id": {"uuid"}}passes
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
  <circle cx="12" cy="12" r="10"/>
  <path d="M12 16v-4M12 8h.01"/>
</svg>

<span>`confirmed` cross-field semantics</span>

The confirmed rule on field <X> looks for a sibling field named <X>_confirmation in the input map and compares the two with reflect.DeepEqual. The convention is fixed; the suffix is not configurable. If the confirmation field is missing or differs, validation fails with "The <X> confirmation does not match.".

rules := validation.Rules{
    "password": {"required", "min:8", "confirmed"},
}

// Both fields must arrive in the request:
//   password=secret123
//   password_confirmation=secret123

confirmed returns success when the source field is nil, so combine it with required if the field itself is mandatory. Use same instead when you need to match against an arbitrary field name.

Highlights

required

Field must be present and not empty (nil, "", empty slice, empty map all fail):

rules := validation.Rules{"name": {"required"}}

nullable / filled / present

nullable is a marker: it always passes and lets a value be nil or empty without other rules tripping. filled accepts an absent field but fails when the field is present and empty. present requires the key to exist in the input map but allows an empty value.

rules := validation.Rules{
    "middle_name": {"nullable", "string"},
    "bio":         {"filled"},
    "opt_in":      {"present"},
}

min / max / size / between

These rules adapt to the value’s type. On strings they measure character length; on numbers they compare values; on slices they count elements.

rules := validation.Rules{
    "password": {"min:8"},
    "bio":      {"max:500"},
    "zip_code": {"size:5"},
    "age":      {"numeric", "between:18,65"},
}

confirmed / same / different

Cross-field comparisons. confirmed is a special-cased same that targets <field>_confirmation. same and different accept any sibling field name as their parameter.

rules := validation.Rules{
    "password":         {"required", "confirmed"},
    "password_confirm": {"same:password"},
    "alt_email":        {"different:email"},
}

in / not_in

Value must (or must not) be in a comma-separated list:

rules := validation.Rules{
    "role":     {"in:admin,user,moderator"},
    "username": {"not_in:admin,root,system"},
}

accepted

Field must be yes, on, 1, or true (case-insensitive). Useful for terms-of-service checkboxes:

rules := validation.Rules{"terms": {"accepted"}}

required_if / required_unless / required_with / required_without

Conditional presence rules. Use them when a field is required only when another field has a particular value, or only when another field is present/absent:

rules := validation.Rules{
    "phone":    {"required_if:contact,phone"},
    "company":  {"required_unless:type,personal"},
    "shipping": {"required_with:address"},
    "sku":      {"required_without:gtin"},
}

gt / gte / lt / lte

Strict / inclusive numeric comparisons. The parameter may be a numeric literal or another field name, in which case the threshold is read from that field’s numeric value:

rules := validation.Rules{
    "max_price": {"numeric", "gt:min_price"},
    "qty":       {"numeric", "lte:100"},
}

password

Stricter than min. Defaults to length >= 8 plus at least one upper, lower, digit, and symbol. A single numeric parameter overrides only the length; flag tokens (mixed, num, symbol, length:<n>) tune the requirements:

rules := validation.Rules{
    "pw": {"password:12"},                    // length >= 12, all defaults
    "pw": {"password:8,mixed,num,symbol"},    // explicit
}

regex

Pattern must be anchored with ^...$ and is rejected if it contains obvious catastrophic-backtracking shapes (e.g. (a+)+). Each evaluation is bounded to 10 ms.

rules := validation.Rules{"sku": {"regex:^[A-Z]{3}-\\d{4}$"}}

file / mimes / image

file confirms the value carries *multipart.FileHeader-shaped metadata. mimes checks the filename’s extension (no content sniffing) against a comma-separated allowlist. image is a shortcut for the common image extensions (jpg, jpeg, png, gif, webp, bmp, svg, heic, heif, avif).

rules := validation.Rules{
    "avatar":   {"image", "max:2048"},
    "document": {"file", "mimes:pdf,docx"},
}

unique / exists (database rules)

Registered only when validation has access to an orm.Database. vform.Form[T] wires this automatically from ctx.Services.DB; with the lower-level entry points use validation.CheckWithDB / validation.CheckDataWithDB.

rules := validation.Rules{
    "email":   {"required", "email", "unique:users,email"},
    "team_id": {"required", "exists:teams,id"},
}

// On update, exclude the current row by id:
rules = validation.Rules{
    "email": {"required", "email", "unique:users,email," + userID},
}

API Reference

Top-level entry points

Check, CheckData, CheckWithDB, and CheckDataWithDB are the package’s public entry points. They all return a *Result whose HasErrors(), All(), Messages(), First(), and Old() methods drive both JSON responses and view-side flash data.

Check

Validate an HTTP request (form values or JSON body, auto-detected from Content-Type):

func handler(c *router.Context) error {
    rules := validation.Rules{
        "email": {"required", "email"},
        "name":  {"required", "string"},
    }

    result := validation.Check(c.Request, rules)
    if result.HasErrors() {
        return c.JSON(422, map[string]interface{}{
            "errors": result.Messages(),
        })
    }
    // ... bind and continue
    return nil
}

CheckData

Validate a pre-extracted map[string]interface{}:

data := map[string]interface{}{"email": "user@example.com", "age": 25}
result := validation.CheckData(data, validation.Rules{
    "email": {"required", "email"},
    "age":   {"required", "numeric", "min:18"},
})
if err := result.Err(); err != nil {
    // err wraps validation.ErrValidationFailed and unwraps to ValidationErrors
    return err
}

CheckWithDB / CheckDataWithDB

Same as above but with unique and exists rules registered against an orm.Database:

result := validation.CheckWithDB(c.Request, rules, c.Services.DB)

Result methods

MethodReturns
HasErrors()bool: true when at least one field failed
First(field)first message for field, or ""
All()map[string]string, first message per field (Inertia-friendly shape)
Messages()map[string][]string, all messages per field
Err()error wrapping ErrValidationFailed; nil on success. Satisfies errors.As(&ValidationErrors{})
Old()map[string]interface{} of original input with password / secret / token fields stripped, suitable for flashing

ValidationErrors

When you unwrap Result.Err() with errors.As(&validation.ValidationErrors{}), you get the lower-level shape:

var ve validation.ValidationErrors
if errors.As(result.Err(), &ve) {
    ve.Count()                     // total error count
    ve.HasError("email")           // bool
    ve.First("email")              // first message
    ve.All()                       // map[string][]string
    ve.HasRule("email", "required")// bool, prefer over substring match
    ve.RulesFor("email")           // []string of rule names that failed
}

errors.Is(result.Err(), validation.ErrValidationFailed) works for the generic “validation failed” branch.

Custom Validation Rules

validation.Rules is the type for the rule set; validation.RuleHandler is the signature for a custom rule. Custom rules must be registered on a Validator instance returned by validation.NewValidator(). There is no global rule registry; this keeps custom rules opt-in per validator.

import (
    "fmt"
    "unicode"

    "github.com/velocitykode/velocity/validation"
)

func newAppValidator() validation.Validator {
    v := validation.NewValidator()
    v.RegisterRule("strong_password", func(
        field string,
        value interface{},
        params []string,
        data map[string]interface{},
    ) error {
        pw, ok := value.(string)
        if !ok {
            return fmt.Errorf("The %s field must be a string.", field)
        }

        var hasUpper, hasLower, hasNumber, hasSpecial bool
        for _, r := range pw {
            switch {
            case unicode.IsUpper(r):
                hasUpper = true
            case unicode.IsLower(r):
                hasLower = true
            case unicode.IsNumber(r):
                hasNumber = true
            case unicode.IsPunct(r) || unicode.IsSymbol(r):
                hasSpecial = true
            }
        }
        if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
            return fmt.Errorf("The %s must contain uppercase, lowercase, number, and special character.", field)
        }
        return nil
    })
    return v
}

// Usage
v := newAppValidator()
result, err := v.Validate(data, validation.Rules{
    "password": {"required", "min:8", "strong_password"},
})

For most adopters the built-in password rule is enough; reach for a custom rule only when the policy is genuinely app-specific.

Custom Error Messages

The high-level entry points accept a variadic Messages map keyed by "field.rule":

messages := validation.Messages{
    "email.required":   "Email is required",
    "email.email":      "Invalid email format",
    "password.required": "Password is required",
    "password.min":     "Password must be at least 8 characters",
}

result := validation.Check(c.Request, rules, messages)

When using a validation.NewValidator() instance directly, call SetMessages once before invoking Validate / ValidateRequest. From a form-request, implement the WithMessages interface and vform.Form[T] will pass them through.

Best Practices

1. Validate at the edge

Validate as soon as input enters the handler, before any business logic:

func CreateUser(c *router.Context) error {
    result := validation.Check(c.Request, validation.Rules{
        "email": {"required", "email"},
        "name":  {"required", "string"},
    })
    if result.HasErrors() {
        return c.JSON(422, map[string]interface{}{
            "errors": result.Messages(),
        })
    }
    // ... bind + business logic
    return nil
}

2. Prefer vform.Form[T] for HTTP

Form requests bundle binding, validation, flashing, and redirect-back into one call. Use validation.Check* directly only when you need a custom render path or you’re validating data that isn’t an HTTP request.

3. Provide clear error messages

messages := validation.Messages{
    "email.email":    "Please enter a valid email address",
    "password.min":   "Your password needs to be at least 8 characters",
    "terms.accepted": "You must accept the terms of service",
}

4. Order rules from general to specific

rules := validation.Rules{
    // Good: required first, then type, then constraints.
    "age": {"required", "numeric", "min:18", "max:120"},
}

5. Reuse rule sets

var (
    EmailRules    = []string{"required", "email"}
    PasswordRules = []string{"required", "min:8", "strong_password"}
    PhoneRules    = []string{"required", "numeric", "size:10"}
)

rules := validation.Rules{
    "email":    EmailRules,
    "password": PasswordRules,
    "phone":    PhoneRules,
}

Complete Example

Here’s a full user-registration endpoint using validation.CheckWithDB (so the unique rule resolves against the database):

package handlers

import (
    "github.com/velocitykode/velocity/router"
    "github.com/velocitykode/velocity/validation"
)

type UserHandler struct{}

func (uc *UserHandler) Register(c *router.Context) error {
    rules := validation.Rules{
        "name":                  {"required", "string", "min:2", "max:100"},
        "email":                 {"required", "email", "unique:users,email"},
        "password":              {"required", "min:8", "confirmed"},
        "password_confirmation": {"required"},
        "age":                   {"required", "numeric", "min:18"},
        "terms":                 {"required", "accepted"},
        "newsletter":            {"nullable", "boolean"},
    }

    messages := validation.Messages{
        "name.required":      "Please tell us your name",
        "name.min":           "Name must be at least 2 characters",
        "email.required":     "We need your email address",
        "email.email":        "Please enter a valid email address",
        "email.unique":       "That email is already registered",
        "password.required":  "Password is required",
        "password.min":       "Password must be at least 8 characters",
        "password.confirmed": "Passwords do not match",
        "age.required":       "Please provide your age",
        "age.min":            "You must be at least 18 years old",
        "terms.accepted":     "You must accept the terms of service",
    }

    result := validation.CheckWithDB(c.Request, rules, c.Services.DB, messages)
    if result.HasErrors() {
        return c.JSON(422, map[string]interface{}{
            "message": "Validation failed",
            "errors":  result.Messages(),
        })
    }

    var input struct {
        Name, Email, Password string
        Age                   int
        Newsletter            bool
    }
    if err := c.BindAuto(&input); err != nil {
        return err
    }

    user := &User{
        Name:            input.Name,
        Email:           input.Email,
        Password:        hashPassword(input.Password),
        Age:             input.Age,
        NewsletterOptIn: input.Newsletter,
    }
    if err := user.Save(); err != nil {
        return c.JSON(500, map[string]string{"error": "Failed to create user"})
    }

    return c.JSON(201, map[string]interface{}{
        "message": "Registration successful",
        "user":    user,
    })
}

Testing

func TestValidation(t *testing.T) {
    data := map[string]interface{}{
        "email":    "user@example.com",
        "password": "secret12",
        "age":      25,
    }

    rules := validation.Rules{
        "email":    {"required", "email"},
        "password": {"required", "min:8"},
        "age":      {"required", "numeric", "min:18"},
    }

    result := validation.CheckData(data, rules)
    assert.False(t, result.HasErrors())
}

func TestValidationErrors(t *testing.T) {
    data := map[string]interface{}{
        "email": "invalid-email",
        "age":   "not-a-number",
    }

    rules := validation.Rules{
        "email": {"required", "email"},
        "age":   {"required", "numeric"},
    }

    result := validation.CheckData(data, rules)
    assert.True(t, result.HasErrors())

    var ve validation.ValidationErrors
    require.True(t, errors.As(result.Err(), &ve))
    assert.True(t, ve.HasError("email"))
    assert.True(t, ve.HasError("age"))
    assert.Equal(t, 2, ve.Count())
}
  • Form Requests - HTTP-handler entry point that wraps validation with binding, flashing, and redirect-back
  • Middleware - where global input shaping (CSRF, rate limit, body parsing) runs before validation