Skip to main content
Version: 0.1.2

Custom Validators

Create your own validation constraints for domain-specific business rules. Pedantigo provides two complementary approaches: field-level validators for reusable validation logic, and struct-level validators for complex cross-struct validation.

Field-Level Validators

Field-level validators allow you to define custom validation constraints that can be applied to any field via struct tags.

Defining a Field Validator

A field validator is a function that receives a value and optional parameter, and returns an error if validation fails:

type ValidationFunc func(value any, param string) error

Simple Example:

// Phone number validator for US numbers
func ValidateUSPhone(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

if len(str) != 10 {
return errors.New("must be exactly 10 digits")
}

for _, ch := range str {
if ch < '0' || ch > '9' {
return errors.New("must contain only digits")
}
}

return nil
}

Validator with Parameters:

The param argument contains any value specified after = in the tag. This allows you to make validators configurable:

// Custom range validator for strings based on length
func ValidateStringRange(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

parts := strings.Split(param, "-")
if len(parts) != 2 {
return errors.New("invalid parameter format, use min-max")
}

minLen, _ := strconv.Atoi(parts[0])
maxLen, _ := strconv.Atoi(parts[1])

if len(str) < minLen || len(str) > maxLen {
return fmt.Errorf("must be between %d and %d characters", minLen, maxLen)
}

return nil
}

Registering with RegisterValidation

Register your custom validator to make it available in struct tags:

func RegisterValidation(name string, fn ValidationFunc) error

Important constraints:

  • Cannot override built-in validators (required, email, min, max, etc.)
  • Returns an error if the name is reserved
  • Clears the validator cache after registration
  • Thread-safe for concurrent use

Registration Example:

package main

import (
"errors"
"strings"
"github.com/smrutai/pedantigo"
)

func init() {
// Register US phone validator
pedantigo.RegisterValidation("us_phone", ValidateUSPhone)

// Register credit card validator
pedantigo.RegisterValidation("credit_card", ValidateCreditCard)
}

// Validates US phone numbers: 10 digits
func ValidateUSPhone(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, str)

if len(digits) != 10 {
return errors.New("must contain exactly 10 digits")
}

return nil
}

// Validates credit card numbers using Luhn algorithm
func ValidateCreditCard(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, str)

if len(digits) < 13 || len(digits) > 19 {
return errors.New("must be between 13 and 19 digits")
}

// Luhn algorithm implementation...
return nil
}

Using Custom Validators in Struct Tags

Once registered, use custom validators in the pedantigo struct tag:

package main

import "github.com/smrutai/pedantigo"

type User struct {
Email string `json:"email" pedantigo:"required,email"`
Phone string `json:"phone" pedantigo:"us_phone"`
}

type Payment struct {
CardNumber string `json:"card_number" pedantigo:"required,credit_card"`
}

func main() {
// Use the validator like any built-in constraint
data := []byte(`{
"email": "user@example.com",
"phone": "5551234567"
}`)

user, err := pedantigo.Unmarshal[User](data)
if err != nil {
// Handle validation errors
}
}

Custom Validators with Parameters

Use the param argument to pass configuration to your validator:

// Generic length validator
func ValidateLength(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

expectedLen, err := strconv.Atoi(param)
if err != nil {
return errors.New("invalid parameter: expected number")
}

if len(str) != expectedLen {
return fmt.Errorf("must be exactly %d characters", expectedLen)
}

return nil
}

// Register and use with parameter
pedantigo.RegisterValidation("length", ValidateLength)

type Product struct {
SKU string `json:"sku" pedantigo:"length=12"` // Exactly 12 chars
UPC string `json:"upc" pedantigo:"length=13"` // Exactly 13 chars
}

Struct-Level Validators

For validation that involves the entire struct or multiple fields, use two approaches: the Validatable interface (recommended for simplicity) or RegisterStructValidation (for global registration).

The Validatable Interface (Simpler Approach)

Implement the Validatable interface on your struct for automatic cross-field validation:

type Validatable interface {
Validate() error
}

Example:

type PasswordChange struct {
CurrentPassword string `json:"current_password" pedantigo:"required,minLength=8"`
NewPassword string `json:"new_password" pedantigo:"required,minLength=8"`
Confirm string `json:"confirm" pedantigo:"required,eqfield=NewPassword"`
}

// Implement Validatable for custom business logic
func (p PasswordChange) Validate() error {
if p.CurrentPassword == p.NewPassword {
return errors.New("new_password must differ from current_password")
}
return nil
}

// Usage
data := []byte(`{
"current_password": "OldPass123",
"new_password": "NewPass123",
"confirm": "NewPass123"
}`)

change, err := pedantigo.Unmarshal[PasswordChange](data)
// Validate() is called automatically after field validation passes

The Validatable approach is recommended for most use cases because:

  • Validation logic stays with your struct definition
  • Self-contained and easy to read
  • No global registration needed
  • Works seamlessly with struct tag constraints

RegisterStructValidation (Global Registration)

For scenarios where you want global validation registration or validation of types you don't own:

func RegisterStructValidation[T any](fn StructLevelFunc[T]) error

Where StructLevelFunc has the signature:

type StructLevelFunc[T any] func(obj *T) error

Example:

type Order struct {
Items []OrderItem `json:"items" pedantigo:"required"`
TotalPrice float64 `json:"total_price" pedantigo:"required,gt=0"`
DiscountPercent int `json:"discount_percent" pedantigo:"min=0,max=100"`
}

type OrderItem struct {
Product string `json:"product" pedantigo:"required"`
Qty int `json:"qty" pedantigo:"required,min=1"`
Price float64 `json:"price" pedantigo:"required,gt=0"`
}

// Register struct-level validation
func init() {
pedantigo.RegisterStructValidation[Order](ValidateOrder)
}

// Validates cross-struct relationships and business rules
func ValidateOrder(order *Order) error {
// Calculate expected total
var expectedTotal float64
for _, item := range order.Items {
expectedTotal += item.Price * float64(item.Qty)
}

// Apply discount
expectedTotal = expectedTotal * (1 - float64(order.DiscountPercent) / 100)

// Verify total price matches
if math.Abs(expectedTotal - order.TotalPrice) > 0.01 {
return fmt.Errorf("total_price does not match items total (expected %.2f, got %.2f)",
expectedTotal, order.TotalPrice)
}

return nil
}

func main() {
data := []byte(`{
"items": [
{"product": "Widget", "qty": 2, "price": 19.99},
{"product": "Gadget", "qty": 1, "price": 49.99}
],
"total_price": 89.97,
"discount_percent": 0
}`)

order, err := pedantigo.Unmarshal[Order](data)
// ValidateOrder is called automatically after field validation
}

Key differences from Validatable:

  • Called after field-level validation for the struct
  • Global registration means it applies to all instances
  • Useful for validating types from external packages
  • Receives a pointer to the struct

Best Practices

Return Clear Error Messages

Your validators should return descriptive error messages that help users understand what went wrong:

// Bad
func ValidateEmail(value any, param string) error {
if !isValidEmail(value.(string)) {
return errors.New("invalid") // Too vague
}
return nil
}

// Good
func ValidateEmail(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

if !isValidEmail(str) {
return errors.New("must be a valid email address (format: user@domain.com)")
}

return nil
}

Handle Type Assertions Safely

Always check that the value is the expected type:

func ValidatePositive(value any, param string) error {
// Check type first
num, ok := value.(float64)
if !ok {
// Try int
if intVal, ok := value.(int); ok {
num = float64(intVal)
} else {
return errors.New("must be a number")
}
}

if num <= 0 {
return errors.New("must be greater than zero")
}

return nil
}

Make Validators Composable

Design validators to be independent and composable with other constraints:

// These work together
type Username struct {
Name string `json:"name" pedantigo:"required,alphanumeric,minLength=3,maxLength=20,username_available"`
}

// username_available checks if the username is not in the reserved list
func ValidateUsernameAvailable(value any, param string) error {
username, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

reserved := map[string]bool{
"admin": true,
"root": true,
"system": true,
}

if reserved[strings.ToLower(username)] {
return errors.New("is a reserved username")
}

return nil
}

Validate Input Format in Validators

Don't assume input is in the correct format; validate defensively:

func ValidateRange(value any, param string) error {
// Validate parameter format
parts := strings.Split(param, "-")
if len(parts) != 2 {
return fmt.Errorf("invalid parameter format: expected 'min-max', got '%s'", param)
}

min, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("invalid minimum value: %w", err)
}

max, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("invalid maximum value: %w", err)
}

// Validate actual value
num, ok := value.(int)
if !ok {
return errors.New("must be an integer")
}

if num < min || num > max {
return fmt.Errorf("must be between %d and %d", min, max)
}

return nil
}

Document Your Validators

Provide clear documentation for custom validators:

// ValidateSlug validates URL-friendly slugs.
// Format: lowercase letters, numbers, and hyphens, 3-50 characters.
// Example: "my-awesome-blog-post"
func ValidateSlug(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

if len(str) < 3 || len(str) > 50 {
return errors.New("must be between 3 and 50 characters")
}

if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(str) {
return errors.New("must contain only lowercase letters, numbers, and hyphens")
}

return nil
}

Complete Example

Here's a comprehensive example combining field-level and struct-level validators:

package main

import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/smrutai/pedantigo"
)

// Custom field validators
func ValidateUSPhone(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, str)

if len(digits) != 10 {
return errors.New("must contain exactly 10 digits")
}

return nil
}

func ValidateHexColor(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

if !regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`).MatchString(str) {
return errors.New("must be valid hex color format (#RRGGBB)")
}

return nil
}

func ValidateSlug(value any, param string) error {
str, ok := value.(string)
if !ok {
return errors.New("must be a string")
}

if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(str) {
return errors.New("must contain only lowercase letters, numbers, and hyphens")
}

return nil
}

type ThemeConfig struct {
Name string `json:"name" pedantigo:"required,minLength=3,maxLength=50"`
Slug string `json:"slug" pedantigo:"required,slug"`
PrimaryColor string `json:"primary_color" pedantigo:"required,hex_color"`
SecondaryColor string `json:"secondary_color" pedantigo:"hex_color"`
AccentColor string `json:"accent_color" pedantigo:"hex_color"`
}

// Validate custom cross-field rules
func (t ThemeConfig) Validate() error {
if t.PrimaryColor == t.SecondaryColor {
return errors.New("primary and secondary colors must be different")
}
return nil
}

type UserProfile struct {
Email string `json:"email" pedantigo:"required,email"`
Phone string `json:"phone" pedantigo:"us_phone"`
Theme ThemeConfig `json:"theme" pedantigo:"required"`
Bio string `json:"bio" pedantigo:"maxLength=500"`
}

func init() {
// Register custom field validators
pedantigo.RegisterValidation("us_phone", ValidateUSPhone)
pedantigo.RegisterValidation("hex_color", ValidateHexColor)
pedantigo.RegisterValidation("slug", ValidateSlug)
}

func main() {
// Valid profile
validData := []byte(`{
"email": "user@example.com",
"phone": "5551234567",
"theme": {
"name": "Ocean Blue",
"slug": "ocean-blue",
"primary_color": "#0066CC",
"secondary_color": "#00CC66"
},
"bio": "Software engineer and open source enthusiast"
}`)

profile, err := pedantigo.Unmarshal[UserProfile](validData)
if err == nil {
fmt.Printf("Profile created: %s\n", profile.Email)
}

// Invalid profile - multiple validation errors
invalidData := []byte(`{
"email": "not-an-email",
"phone": "555-123",
"theme": {
"name": "X",
"slug": "Ocean Blue",
"primary_color": "#0066CC",
"secondary_color": "#0066CC"
}
}`)

_, err = pedantigo.Unmarshal[UserProfile](invalidData)
if err != nil {
var ve *pedantigo.ValidationError
if errors.As(err, &ve) {
fmt.Println("Validation errors:")
for _, fieldErr := range ve.Errors {
fmt.Printf(" %s: %s\n", fieldErr.Field, fieldErr.Message)
}
}
}
}

Summary

Custom validators provide two powerful patterns:

FeatureUse Case
Field-Level ValidatorsReusable constraints applied via struct tags
RegisterValidationGlobal registration for field validators
Validatable InterfaceCustom struct validation with access to all fields
RegisterStructValidationGlobal registration for struct-level validation

Combine these approaches to handle validation requirements from simple format checks to complex business logic validation across multiple fields and nested structures.