Skip to content

Custom Validation Rules — Reusable Validators in Zod, Joi, Yup, and Pydantic

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you'll learn about Custom Validation Rules. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Custom validation rules extend schema libraries with application-specific logic, handling business rules that built-in validators cannot Express.

What You'll Learn

By the end of this lesson, you will create custom validators in Zod, Joi, Yup, and Pydantic, build reusable validation chains, and compose complex business rules.

Why It Matters

No validation library can cover every business requirement. Custom validators let you enforce domain-specific rules like tax ID formats, discount code logic, or subscription tier compatibility.

Real-World Use

Durga Antivirus Pro uses custom validators to check license keys against a checksum algorithm that is specific to its licensing system — a rule no general-purpose library supports.

Custom Validation Pattern

flowchart LR
    Schema[Schema Definition] --> Custom[Custom Rule]
    Custom --> Check[Business Logic Check]
    Check --> Pass{Condition Met?}
    Pass -->|Yes| Next[Next Validator]
    Pass -->|No| Error[Custom Error]
    Error --> Collect[Error Collection]
    Next --> Final[Validation Complete]

Custom Rules in Pydantic (Python)

# custom_pydantic.py
from pydantic import BaseModel, Field, field_validator, ValidationInfo
from typing import Optional
import re

class ProductValidator(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)
    sku: str
    category: str
    discount_code: Optional[str] = None
    quantity: int = Field(ge=0)

    @field_validator('sku')
    @classmethod
    def validate_sku(cls, v: str) -> str:
        if not re.match(r'^[A-Z]{3}-\d{4}-[A-Z]{2}$', v):
            raise ValueError('SKU must match format XXX-1234-XX')
        return v

    @field_validator('price')
    @classmethod
    def validate_price_precision(cls, v: float) -> float:
        if round(v, 2) != v:
            raise ValueError('Price must have at most 2 decimal places')
        return v

    @field_validator('discount_code')
    @classmethod
    def validate_discount(cls, v: Optional[str], info: ValidationInfo) -> Optional[str]:
        if v is not None:
            if not re.match(r'^DISC-\d{4,8}$', v):
                raise ValueError('Discount code must match DISC-XXXXXXXX')
            data = info.data
            if data.get('category') == 'electronics' and data.get('price', 0) < 100:
                raise ValueError('Discount code requires minimum $100 for electronics')
        return v

    @field_validator('quantity')
    @classmethod
    def validate_bulk_order(cls, v: int, info: ValidationInfo) -> int:
        if v > 100 and info.data.get('category') == 'perishable':
            raise ValueError('Bulk orders over 100 not allowed for perishable items')
        return v

try:
    p = ProductValidator(name="Widget", price=29.99, sku="ELE-2024-01", category="electronics")
    print(f"Valid: {p.model_dump()}")
except Exception as e:
    print(f"Error: {e}")

try:
    p2 = ProductValidator(name="Widget", price=29.99, sku="invalid", category="electronics", discount_code="DISC-5000")
    print(f"Valid: {p2}")
except Exception as e:
    print(f"Error: {e}")

try:
    p3 = ProductValidator(name="Widget", price=29.999, sku="ELE-2024-01", category="electronics", quantity=200)
    print(f"Valid: {p3}")
except Exception as e:
    print(f"Error: {e}")

Expected output:

Valid: {'name': 'Widget', 'price': 29.99, 'sku': 'ELE-2024-01', 'category': 'electronics', 'discount_code': None, 'quantity': 0}
Error: 1 validation error for ProductValidator
sku: Value error, SKU must match format XXX-1234-XX
Error: 1 validation error for ProductValidator
price: Value error, Price must have at most 2 decimal places

Custom Rules in Zod (TypeScript/JavaScript)

// custom-zod.ts
import { z } from 'zod';

const taxIdSchema = z.string().refine(
  (val) => {
    const cleaned = val.replace(/[\s-]/g, '');
    if (cleaned.length !== 11) return false;
    const digits = cleaned.split('').map(Number);
    const sum1 = digits.slice(0, 9).reduce((acc, d, i) => acc + d * (10 - i), 0);
    const rem1 = (sum1 * 10) % 11;
    if (rem1 !== digits[9]) return false;
    const sum2 = digits.slice(0, 10).reduce((acc, d, i) => acc + d * (11 - i), 0);
    const rem2 = (sum2 * 10) % 11;
    return rem2 === digits[10];
  },
  { message: 'Invalid tax ID (checksum failed)' }
);

const discountSchema = z.string().refine(
  (val) => {
    if (!val.startsWith('SAVE')) return false;
    const num = parseInt(val.slice(4), 10);
    return !isNaN(num) && num >= 5 && num <= 95;
  },
  { message: 'Discount code must be SAVE followed by a number (5-95)' }
);

const orderSchema = z.object({
  items: z.array(z.object({
    sku: z.string().regex(/^[A-Z]{3}-\d{4}$/),
    quantity: z.number().int().positive()
  })).min(1),
  discount: discountSchema.optional(),
  tax_id: taxIdSchema.optional(),
  shipping_zip: z.string().regex(/^\d{5}(-\d{4})?$/),
});

const validData = {
  items: [{ sku: 'ABC-1234', quantity: 2 }],
  discount: 'SAVE25',
  tax_id: '12345678909',
  shipping_zip: '90210',
};

const result = orderSchema.safeParse(validData);
console.log('Valid:', result.success);

const badData = { items: [], shipping_zip: 'invalid' };
const badResult = orderSchema.safeParse(badData);
console.log('Invalid:', badResult.success, badResult.error?.issues.length, 'issues');

Expected output:

Valid: true
Invalid: false 2 issues

Custom Rules in Joi (JavaScript)

// custom-joi.js
const Joi = require('joi');

const passwordSchema = Joi.string().min(8).custom((value, helpers) => {
  if (!/[A-Z]/.test(value)) {
    return helpers.error('password.uppercase', { message: 'Must contain uppercase letter' });
  }
  if (!/[0-9]/.test(value)) {
    return helpers.error('password.digit', { message: 'Must contain a digit' });
  }
  if (/(.)\1{3,}/.test(value)) {
    return helpers.error('password.repeat', { message: 'No repeated characters 4+ times' });
  }
  if (/password|1234|qwerty/i.test(value)) {
    return helpers.error('password.common', { message: 'Password is too common' });
  }
  return value;
}).messages({
  'password.uppercase': 'Password must contain an uppercase letter',
  'password.digit': 'Password must contain a digit',
  'password.repeat': 'Password cannot have repeated characters 4+ times',
  'password.common': 'Password is too common',
});

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: passwordSchema.required(),
  confirm_password: Joi.any().valid(Joi.ref('password')).required()
    .messages({ 'any.only': 'Passwords do not match' }),
  role: Joi.string().valid('admin', 'user', 'moderator').required(),
  api_key: Joi.string().when('role', {
    is: 'admin',
    then: Joi.string().uuid().required(),
    otherwise: Joi.forbidden()
  })
});

const valid = schema.validate({
  username: 'alice',
  password: 'Secure99!',
  confirm_password: 'Secure99!',
  role: 'admin',
  api_key: '550e8400-e29b-41d4-a716-446655440000'
});
console.log('Valid:', !valid.error);

const invalid = schema.validate({
  username: 'alice',
  password: 'password',
  confirm_password: 'password',
  role: 'admin'
});
console.log('Invalid:', invalid.error?.details.length, 'errors');
invalid.error?.details.forEach(d => console.log(`  ${d.path.join('.')}: ${d.message}`));

Expected output:

Valid: true
Invalid: 2 errors
  password: Password is too common
  api_key: "api_key" is required

Custom Rules in Yup (JavaScript)

// custom-yup.js
const yup = require('yup');

const urlSlugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

const tagSchema = yup.string()
  .lowercase()
  .matches(/^[a-z]+$/, 'Tags must be lowercase letters only')
  .max(20);

const metadataSchema = yup.object().shape({
  title: yup.string().min(1).max(200).required(),
  slug: yup.string()
    .matches(urlSlugRegex, 'Slug must be lowercase with hyphens')
    .required(),
  tags: yup.array().of(tagSchema).max(5).optional(),
  published_date: yup.date().nullable().optional(),
  is_draft: yup.boolean().test(
    'published-draft',
    'Published date required when not a draft',
    function(value) {
      const { published_date } = this.parent;
      if (value === false && !published_date) {
        return false;
      }
      return true;
    }
  ),
  seo_score: yup.number().min(0).max(100).test(
    'seo-threshold',
    'SEO score must be at least 30 for published posts',
    function(value) {
      const { is_draft } = this.parent;
      if (is_draft === false && (value === undefined || value < 30)) {
        return false;
      }
      return true;
    }
  )
});

const validPost = {
  title: 'Custom Validation in Node.js',
  slug: 'custom-validation-nodejs',
  tags: ['node', 'validation'],
  is_draft: false,
  published_date: new Date(),
  seo_score: 85
};

const badPost = {
  title: 'Bad Post',
  slug: 'Bad Slug With Spaces',
  is_draft: false,
};

metadataSchema.validate(validPost).then(() => console.log('Valid post: OK'));
metadataSchema.validate(badPost).catch(err => {
  console.log('Bad post errors:');
  err.inner.forEach(e => console.log(`  ${e.path}: ${e.message}`));
});

Expected output:

Valid post: OK
Bad post errors:
  slug: Slug must be lowercase with hyphens
  published_date: published_date is a required field
  seo_score: SEO score must be at least 30 for published posts

Reusable Custom Rule Registry

# custom_rule_registry.py
from typing import Any, Callable, Dict, List, Optional, Tuple

class ValidationRule:
    def __init__(self, name: str, validate: Callable[[Any, Dict], Optional[str]],
                 params: Optional[Dict] = None):
        self.name = name
        self.validate = validate
        self.params = params or {}

class RuleRegistry:
    def __init__(self):
        self.rules: Dict[str, ValidationRule] = {}

    def register(self, rule: ValidationRule):
        self.rules[rule.name] = rule

    def apply(self, rule_name: str, value: Any, context: Dict = None) -> Optional[str]:
        rule = self.rules.get(rule_name)
        if not rule:
            return f"Unknown rule: {rule_name}"
        return rule.validate(value, context or {})

    def compose(self, rule_names: List[str], value: Any,
                context: Dict = None) -> List[str]:
        errors = []
        for name in rule_names:
            error = self.apply(name, value, context or {})
            if error:
                errors.append(error)
        return errors

registry = RuleRegistry()

registry.register(ValidationRule("non_empty", lambda v, ctx: None if v else "Value required"))
registry.register(ValidationRule("min_length", lambda v, ctx: None if len(str(v)) >= ctx.get('min', 0) else f"Minimum {ctx.get('min')} characters"))
registry.register(ValidationRule("tax_id_checksum", lambda v, ctx: (None if len(str(v)) == 11 else "Tax ID must be 11 digits")))
registry.register(ValidationRule("allowed_values", lambda v, ctx: None if v in ctx.get('values', []) else f"Must be one of {ctx.get('values')}"))

print(f"non_empty(''):           {registry.apply('non_empty', '')}")
print(f"min_length('ab', 3):     {registry.apply('min_length', 'ab', {'min': 3})}")
print(f"compose:                 {registry.compose(['non_empty', 'min_length'], '', {'min': 3})}")
print(f"allowed_values:          {registry.apply('allowed_values', 'gold', {'values': ['bronze', 'silver', 'gold']})}")

Expected output:

non_empty(''):           Value required
min_length('ab', 3):     Minimum 3 characters
compose:                 ['Value required', 'Minimum 3 characters']
allowed_values:          None

Common Mistakes

1. Throwing Instead of Collecting

Custom validators that throw on the first error prevent users from seeing all issues. Collect all errors and return them.

2. Not Reusing Rules

Copying the same custom logic across multiple schemas leads to drift. Register reusable rules centrally.

3. Mixing Sanitization with Validation

Modifying values inside validators (like trimming) changes data silently. Separate sanitization from validation.

4. Not Handling Async Validation

Custom rules that query a database must be async. Sync validators will block the event loop.

5. Over-Customizing Simple Cases

Using custom validators for checks that built-in rules handle (like minLength, pattern) adds unnecessary complexity.

Practice Questions

1. Why create custom validation rules?

Built-in validators cannot express domain-specific business logic like tax ID checksums or discount code algorithms.

2. How do you make a custom validator reusable?

Register it in a rule registry with a name and parameters, then reference it from multiple schemas.

3. What is the difference between a custom validator and a refinement?

A custom validator runs arbitrary business logic. A refinement narrows or transforms a type (e.g., Zod's .refine() vs .superRefine()).

4. How do you handle cross-field validation in custom rules?

Access sibling field values via the parent data object or context parameter passed to the validator.

Challenge

Build a library of custom validators for a subscription API: credit card checksum (Luhn), coupon code expiration and usage limit, referral code format, and plan upgrade compatibility rules.

FAQ

What is a custom validator?

A function that applies business-specific logic beyond what built-in schema rules provide, returning an error or passing the value.

When should I use a custom validator instead of regex?

When the logic needs computation, date math, database lookups, or cross-field checks. Use regex for pure pattern matching.

Can custom validators be async?

Yes. Zod supports async refinements, and Joi/Pydantic support async custom validators for database-dependent checks.

How do I test custom validators?

Unit test the validator function directly with edge cases, then integration test it within the schema against valid and invalid data.

Should I modify data inside a custom validator?

No. Validation should be side-effect free. Data transformation belongs in a separate sanitization or parsing layer.

Mini Project: Subscription Validator with Custom Rules

# subscription_validator.py
from typing import Any, Dict, List, Optional

class SubscriptionValidator:
    VALID_PLANS = ["free", "pro", "enterprise"]
    PLAN_LIMITS = {"free": 1, "pro": 10, "enterprise": 100}

    def validate_plan_change(self, current_plan: str, new_plan: str) -> Optional[str]:
        if current_plan not in self.VALID_PLANS:
            return f"Invalid current plan: {current_plan}"
        if new_plan not in self.VALID_PLANS:
            return f"Invalid new plan: {new_plan}"
        tiers = {p: i for i, p in enumerate(self.VALID_PLANS)}
        if tiers[new_plan] < tiers[current_plan]:
            return f"Cannot downgrade from {current_plan} to {new_plan}"
        return None

    def validate_seats(self, plan: str, seat_count: int) -> Optional[str]:
        limit = self.PLAN_LIMITS.get(plan, 0)
        if seat_count > limit:
            return f"Plan {plan} allows max {limit} seats, got {seat_count}"
        return None

    def validate_coupon(self, coupon: Optional[str], plan: str) -> Optional[str]:
        if coupon is None:
            return None
        if not coupon.startswith("SAVE"):
            return "Invalid coupon format"
        try:
            pct = int(coupon[4:])
            if pct < 5 or pct > 50:
                return "Discount must be between 5-50%"
        except ValueError:
            return "Invalid coupon code"
        return None

    def validate(self, data: Dict) -> List[str]:
        errors = []
        plan = data.get("plan", "free")
        current = data.get("current_plan")

        plan_error = self.validate_plan_change(current, plan) if current else None
        if plan_error:
            errors.append(plan_error)

        seats_error = self.validate_seats(plan, data.get("seats", 1))
        if seats_error:
            errors.append(seats_error)

        coupon_error = self.validate_coupon(data.get("coupon"), plan)
        if coupon_error:
            errors.append(coupon_error)

        return errors

sv = SubscriptionValidator()
print(sv.validate({"plan": "pro", "seats": 5}))
print(sv.validate({"plan": "pro", "seats": 20}))
print(sv.validate({"current_plan": "enterprise", "plan": "free"}))
print(sv.validate({"plan": "enterprise", "seats": 10, "coupon": "SAVE25"}))
print(sv.validate({"plan": "pro", "seats": 5, "coupon": "INVALID"}))

Expected output:

[]
['Plan pro allows max 10 seats, got 20']
['Cannot downgrade from enterprise to free']
[]
['Invalid coupon format']

What's Next

You understand custom validation rules. Next, learn validation libraries comparison, then error messages and localization.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro