Custom Validation Rules — Reusable Validators in Zod, Joi, Yup, and Pydantic
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
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