Skip to content

Custom Rules

While Souuup provides comprehensive built-in rules, you’ll often need custom validation logic for your specific domain requirements. This guide shows you how to create powerful, reusable custom rules.

A rule in Souuup is simply a function that takes a FieldState[T] and returns an error if validation fails:

type Rule[T any] func(FieldState[T]) error

The FieldState[T] contains the value being validated:

type FieldState[T any] struct {
Value T
// ... internal fields
}
// Email validation rule
func ValidEmail(fs u.FieldState[string]) error {
email := fs.Value
if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
return fmt.Errorf("must be a valid email address")
}
return nil
}
// Usage
emailField := u.Field("[email protected]", r.NotZero, ValidEmail)

Create rules that accept parameters by returning a rule function:

// Rule that checks if string contains any of the given substrings
func ContainsAny(substrings []string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
value := fs.Value
for _, substr := range substrings {
if strings.Contains(value, substr) {
return nil // Found one, validation passes
}
}
return fmt.Errorf("must contain at least one of: %v", substrings)
}
}
// Usage
passwordField := u.Field(password,
r.MinS(8),
ContainsAny([]string{"!", "@", "#", "$", "%"}), // Must contain special char
)
import (
"net/mail"
"strings"
)
// Comprehensive email validation using Go's mail package
func ValidEmail(fs u.FieldState[string]) error {
email := fs.Value
if email == "" {
return fmt.Errorf("email cannot be empty")
}
if _, err := mail.ParseAddress(email); err != nil {
return fmt.Errorf("must be a valid email address")
}
return nil
}
// Domain-specific email validation
func EmailFromDomain(domain string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
email := fs.Value
if !strings.HasSuffix(email, "@"+domain) {
return fmt.Errorf("email must be from domain %s", domain)
}
return nil
}
}
// Usage
corporateEmail := u.Field("[email protected]",
ValidEmail,
EmailFromDomain("company.com"),
)
// Prime number validation
func IsPrime(fs u.FieldState[int]) error {
n := fs.Value
if n < 2 {
return fmt.Errorf("must be a prime number (>= 2)")
}
for i := 2; i*i <= n; i++ {
if n%i == 0 {
return fmt.Errorf("must be a prime number")
}
}
return nil
}
// Even number validation
func IsEven(fs u.FieldState[int]) error {
if fs.Value%2 != 0 {
return fmt.Errorf("must be an even number")
}
return nil
}
// Range validation with custom bounds
func InRange[T u.Numeric](min, max T) u.Rule[T] {
return func(fs u.FieldState[T]) error {
value := fs.Value
if value < min || value > max {
return fmt.Errorf("must be between %v and %v (inclusive)", min, max)
}
return nil
}
}
// Percentage validation (0-100)
func ValidPercentage(fs u.FieldState[float64]) error {
value := fs.Value
if value < 0 || value > 100 {
return fmt.Errorf("percentage must be between 0 and 100")
}
return nil
}
import (
"time"
)
// Date format validation
func DateFormat(layout string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
dateStr := fs.Value
if _, err := time.Parse(layout, dateStr); err != nil {
return fmt.Errorf("date must be in format %s", layout)
}
return nil
}
}
// Past date validation
func InThePast(fs u.FieldState[time.Time]) error {
if fs.Value.After(time.Now()) {
return fmt.Errorf("date must be in the past")
}
return nil
}
// Future date validation
func InTheFuture(fs u.FieldState[time.Time]) error {
if fs.Value.Before(time.Now()) {
return fmt.Errorf("date must be in the future")
}
return nil
}
// Age validation from birthdate
func MinimumAge(minAge int) u.Rule[time.Time] {
return func(fs u.FieldState[time.Time]) error {
birthDate := fs.Value
age := int(time.Since(birthDate).Hours() / 24 / 365)
if age < minAge {
return fmt.Errorf("must be at least %d years old", minAge)
}
return nil
}
}
// Business hours validation
func DuringBusinessHours(fs u.FieldState[time.Time]) error {
t := fs.Value
hour := t.Hour()
// Business hours: 9 AM to 5 PM, Monday to Friday
if t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
return fmt.Errorf("must be during business days (Monday-Friday)")
}
if hour < 9 || hour >= 17 {
return fmt.Errorf("must be during business hours (9 AM - 5 PM)")
}
return nil
}

Sometimes you need to validate one field against another:

type PasswordForm struct {
Password string
ConfirmPassword string
}
// Password confirmation rule
func PasswordsMatch(original, confirmation string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
if original != confirmation {
return fmt.Errorf("passwords do not match")
}
return nil
}
}
// Date range validation
func EndAfterStart(startDate, endDate time.Time) u.Rule[time.Time] {
return func(fs u.FieldState[time.Time]) error {
if endDate.Before(startDate) {
return fmt.Errorf("end date must be after start date")
}
return nil
}
}
// Usage
func ValidatePasswordForm(form PasswordForm) error {
schema := u.Schema{
"password": u.Field(form.Password,
r.NotZero,
r.MinS(8),
StrongPassword, // Custom rule
),
"confirm_password": u.Field(form.ConfirmPassword,
r.NotZero,
PasswordsMatch(form.Password, form.ConfirmPassword),
),
}
return u.NewSouuup(schema).Validate()
}
// Credit card validation using Luhn algorithm
func ValidCreditCard(fs u.FieldState[string]) error {
cardNumber := strings.ReplaceAll(fs.Value, " ", "")
if len(cardNumber) < 13 || len(cardNumber) > 19 {
return fmt.Errorf("credit card number must be 13-19 digits")
}
// Luhn algorithm implementation
sum := 0
alternate := false
for i := len(cardNumber) - 1; i >= 0; i-- {
digit := int(cardNumber[i] - '0')
if alternate {
digit *= 2
if digit > 9 {
digit = digit%10 + digit/10
}
}
sum += digit
alternate = !alternate
}
if sum%10 != 0 {
return fmt.Errorf("invalid credit card number")
}
return nil
}
// Stock ticker symbol validation
func ValidStockSymbol(fs u.FieldState[string]) error {
symbol := fs.Value
// Must be 1-5 uppercase letters
if len(symbol) < 1 || len(symbol) > 5 {
return fmt.Errorf("stock symbol must be 1-5 characters")
}
for _, char := range symbol {
if !unicode.IsUpper(char) || !unicode.IsLetter(char) {
return fmt.Errorf("stock symbol must contain only uppercase letters")
}
}
return nil
}
// ISBN validation
func ValidISBN(fs u.FieldState[string]) error {
isbn := strings.ReplaceAll(fs.Value, "-", "")
if len(isbn) == 10 {
return validateISBN10(isbn)
} else if len(isbn) == 13 {
return validateISBN13(isbn)
}
return fmt.Errorf("ISBN must be 10 or 13 digits")
}
func validateISBN10(isbn string) error {
// ISBN-10 validation logic
sum := 0
for i := 0; i < 9; i++ {
digit := int(isbn[i] - '0')
sum += digit * (10 - i)
}
lastChar := isbn[9]
var checkDigit int
if lastChar == 'X' {
checkDigit = 10
} else {
checkDigit = int(lastChar - '0')
}
sum += checkDigit
if sum%11 != 0 {
return fmt.Errorf("invalid ISBN-10")
}
return nil
}

Rules that depend on external context or state:

// Database-dependent validation
type UserService struct {
db *sql.DB
}
func (us *UserService) UniqueEmail(fs u.FieldState[string]) error {
email := fs.Value
var count int
err := us.db.QueryRow("SELECT COUNT(*) FROM users WHERE email = ?", email).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check email uniqueness")
}
if count > 0 {
return fmt.Errorf("email address is already in use")
}
return nil
}
// Environment-dependent validation
func ValidEnvironment(allowedEnvs []string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
env := fs.Value
for _, allowed := range allowedEnvs {
if env == allowed {
return nil
}
}
return fmt.Errorf("environment must be one of: %v", allowedEnvs)
}
}
// Combine multiple rules into one
func StrongPassword(fs u.FieldState[string]) error {
password := fs.Value
// Check minimum length
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
// Check for uppercase letter
hasUpper := false
hasLower := false
hasDigit := false
hasSpecial := false
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
for _, char := range password {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsDigit(char):
hasDigit = true
case strings.ContainsRune(specialChars, char):
hasSpecial = true
}
}
var missing []string
if !hasUpper {
missing = append(missing, "uppercase letter")
}
if !hasLower {
missing = append(missing, "lowercase letter")
}
if !hasDigit {
missing = append(missing, "digit")
}
if !hasSpecial {
missing = append(missing, "special character")
}
if len(missing) > 0 {
return fmt.Errorf("password must contain at least one: %s",
strings.Join(missing, ", "))
}
return nil
}
// Rule factory for common patterns
func MustContainAll(requirements []string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
value := strings.ToLower(fs.Value)
for _, req := range requirements {
if !strings.Contains(value, strings.ToLower(req)) {
return fmt.Errorf("must contain '%s'", req)
}
}
return nil
}
}
// Rule that applies only under certain conditions
func RequiredIf(condition bool, message string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
if condition && fs.Value == "" {
return fmt.Errorf(message)
}
return nil
}
}
// Conditional validation based on other field values
func RequiredIfFieldEquals(otherValue, expectedValue, fieldName string) u.Rule[string] {
return func(fs u.FieldState[string]) error {
if otherValue == expectedValue && fs.Value == "" {
return fmt.Errorf("field is required when %s is '%s'", fieldName, expectedValue)
}
return nil
}
}
// Usage example
type ShippingForm struct {
Method string // "pickup" or "delivery"
DeliveryAddress string
}
func ValidateShipping(form ShippingForm) error {
schema := u.Schema{
"method": u.Field(form.Method,
r.NotZero,
r.InS([]string{"pickup", "delivery"}),
),
"delivery_address": u.Field(form.DeliveryAddress,
RequiredIfFieldEquals(form.Method, "delivery", "method"),
// Only validate address format if it's required
func(fs u.FieldState[string]) error {
if form.Method == "delivery" && len(fs.Value) < 10 {
return fmt.Errorf("delivery address must be at least 10 characters")
}
return nil
},
),
}
return u.NewSouuup(schema).Validate()
}

Always test your custom rules thoroughly:

func TestValidEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid email", "[email protected]", false},
{"missing @", "userexample.com", true},
{"missing domain", "user@", true},
{"empty email", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := u.FieldState[string]{Value: tt.email}
err := ValidEmail(fs)
if (err != nil) != tt.wantErr {
t.Errorf("ValidEmail() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

Single Responsibility

Each rule should validate one specific aspect. Combine multiple rules rather than creating one complex rule.

// Good
r.MinS(8), StrongPassword, NotCommonPassword
// Avoid
ComplexPasswordRule() // Does everything

Clear Error Messages

Provide specific, actionable error messages that help users understand what’s wrong.

// Good
"password must contain at least one uppercase letter"
// Avoid
"invalid password"

Handle Edge Cases

Consider empty values, nil pointers, and boundary conditions in your rules.

func ValidEmail(fs u.FieldState[string]) error {
if fs.Value == "" {
return fmt.Errorf("email cannot be empty")
}
// ... rest of validation
}

Make Rules Reusable

Create parameterized rules that can be configured for different use cases.

func MinAge(age int) u.Rule[time.Time] {
return func(fs u.FieldState[time.Time]) error {
// Implementation
}
}

HTTP Integration

Learn how to use custom rules in web API validation.

Examples →

API Reference

Complete reference of all available functions and interfaces.

API Reference →

Contributing

Consider contributing useful custom rules back to the Souuup project.

GitHub →