Stop Copy-Pasting Validation Logic: A Complete Guide to Validex and Tree-Shakeable Zod Schemas
The Validation Copy-Paste Trap We All Fall Into
If you've worked on more than one Zod-based project, you've probably experienced this moment of déjà vu: you're setting up validation for user registration, and you suddenly realize you've written this exact email validation logic before. In a different repo. With slightly different rules.
You dig through your git history, find that old .refine() closure, adjust the error messages to match your current project's i18n setup, tweak the regex because you remember the last one didn't quite work, and then you do the same dance for password validation, phone numbers, usernames, and credit card fields.
This is the hidden cost of building validation schemas in modern applications. Zod gives us incredible type safety and composability, but validation rules are surprisingly nuanced. An email isn't just "something with an @ sign." A password needs to consider length, complexity, common patterns, and consecutive character repetition. A phone number varies wildly depending on region. And suddenly, you're maintaining 200 lines of validation logic that should really be a library.
I've seen teams solve this by creating internal validation packages, but they inevitably ship with inconsistent error handling, missing internationalization support, and bloated bundles because they include checks nobody needs. Validex solves this problem differently: it's a purpose-built validation layer on top of Zod that provides 25 production-ready rules with intelligent bundling and a unified error architecture.
Understanding the Root Cause: Why Validation Logic Is Hard to Share
Before diving into the solution, let's understand why validation has traditionally been difficult to abstract away:
Inconsistent Error Handling. When you throw custom errors in Zod refinements, the error structure varies wildly. Some are strings, some are objects, some include context. This makes it nearly impossible to handle errors consistently in your UI or log them reliably.
Configuration Complexity. Should your email validator block disposable domains? What about your password rules—do you need to check against a list of 100 common passwords or 10,000? These decisions change per project, and there's no standard way to pass configuration through a validation stack.
Bundle Size Creep. Validation data is heavy. Disposable email domain lists have thousands of entries. Common password databases have millions. libphonenumber metadata is huge. If you include all of this in your main bundle, even for users who never create an account, you're paying a real performance cost.
Type Safety Gaps. Zod gives us beautiful TypeScript support, but custom refinements often lose that type information, requiring extra type assertions and reducing your IDE's ability to help you.
Validex addresses each of these through deliberate architectural choices.
How Validex Fixes the Problem: Architecture and Design
Validex operates as a specialized layer on top of Zod 4, providing:
Structured Error Architecture. Every validation error follows the pattern validation.messages.{namespace}.{code}. An email that fails for being disposable returns validation.messages.email.disposableBlocked, not "This email domain is on the blocklist." This makes errors machine-readable and i18n-friendly.
Three-Tier Configuration Merging. Built-in defaults define sensible behavior (like minimum password length). Call setup() once to override defaults globally for your project. Individual rule calls can override further. This eliminates repetition without sacrificing flexibility.
Lazy-Loaded Data Checks. Heavy validation data—blocklists, dictionaries, metadata—only loads when you actually use that rule. Your JavaScript bundle doesn't grow for features you don't need.
141 Specific Error Codes. Instead of generic "invalid," you get password.maxConsecutive, creditCard.issuerBlocked, jwt.expired. Your frontend can show precise, helpful feedback.
Getting Started: Installation and Basic Setup
First, install the package:
npm install @validex/core zod
Now, let's build a real registration schema. Here's a complete example that demonstrates the three-tier configuration system:
import { z } from 'zod'
import {
Email,
Password,
Username,
PhoneNumber,
setup,
validate
} from '@validex/core'
// Tier 1: Set global defaults for your entire project
// This runs once, typically in your app initialization
setup({
email: {
blockDisposable: true,
},
password: {
blockCommon: '1k', // Check against 1000 most common passwords
requireUppercase: true,
requireNumbers: true,
},
username: {
minLength: 3,
maxLength: 20,
pattern: 'alphanumeric', // Predefined patterns available
},
phoneNumber: {
defaultCountry: 'US',
},
})
// Tier 2: Define your schema with per-field overrides
const registrationSchema = z.object({
email: Email({
// This overrides the global setting just for this field
blockDisposable: false,
}),
password: Password({
length: { min: 12, max: 128 },
// blockCommon uses global setting (1k)
}),
username: Username({
// Uses global settings
}),
phoneNumber: PhoneNumber(),
agreeToTerms: z.boolean().refine(val => val === true, {
message: 'You must agree to terms',
}),
})
// Now validate incoming data
const result = registrationSchema.safeParse({
email: 'user@example.com',
password: 'MySecure$Password123!',
username: 'johndoe',
phoneNumber: '+1 (555) 123-4567',
agreeToTerms: true,
})
if (!result.success) {
// Errors are structured and ready for i18n
result.error.errors.forEach(error => {
console.log(error.code) // e.g., "validation.messages.email.disposableBlocked"
console.log(error.message) // User-friendly message
console.log(error.path) // ["email"]
})
}
Handling Complex Validation Scenarios
Let's look at how Validex handles more sophisticated requirements. Consider a fintech application that needs strict password rules and international phone number validation:
import {
Password,
PhoneNumber,
CreditCard,
JWT,
IBAN,
} from '@validex/core'
const strictFinanceSchema = z.object({
// Password with all security options enabled
password: Password({
length: { min: 14 },
blockCommon: '10k', // Check against 10,000 most common passwords
requireUppercase: true,
requireNumbers: true,
requireSymbols: true,
maxConsecutive: 2, // No more than 2 same characters in a row
blockSequential: true, // No "abc" or "321" patterns
blockKeyboard: true, // No "qwerty" or "asdf" patterns
}),
// Phone with multi-region support
phone: PhoneNumber({
countries: ['US', 'CA', 'MX'], // Allow these countries
allowExtensions: false,
}),
// Credit card with issuer blocking
cardNumber: CreditCard({
allowedIssuers: ['visa', 'mastercard'], // Block Amex for this field
blockTestCards: true,
}),
// JWT validation with expiration checking
apiToken: JWT({
algorithms: ['HS256', 'RS256'],
checkExpiration: true,
checkNotBefore: true,
}),
// IBAN validation with region restrictions
bankAccount: IBAN({
allowedCountries: ['DE', 'FR', 'IT', 'ES'],
}),
})
The beauty here is that you're not writing custom Zod refinements. You're using pre-built, tested validators that handle all the edge cases. The phone number validator uses libphonenumber-js under the hood but only loads that library when you actually use the PhoneNumber rule.
Common Pitfalls and Edge Cases
Pitfall 1: Forgetting That Data Loading Is Async. When you use validators that check against blocklists or dictionaries, those checks might require async operations. Validex handles this automatically—your validators return standard Zod schemas that can be used in async validation contexts.
Pitfall 2: Mixing Error Handling Styles. If you combine Validex validators with custom .refine() closures that don't follow the error structure, you'll end up with inconsistent error codes. Solution: stick with Validex rules wherever possible, or ensure custom refinements follow the validation.messages.{namespace}.{code} pattern.
Pitfall 3: Over-Configuring at Setup. The global setup() should define your defaults, not every possible override. Leave room for per-field customization. Otherwise, you lose flexibility when you encounter a field with different requirements.
Pitfall 4: Bundle Size Assumptions. Validex claims 13 KB for all 25 rules, but this is the gzipped size including all data. If your tree-shaker isn't configured correctly, you might pull in more than necessary. Ensure your build tool properly tree-shakes unused validators.
Bundle Size in Practice
Here's a realistic example: suppose your application only uses Email, Password, and Username validators, but your build includes the entire Validex library.
// ❌ This might include all validators
import * as validex from '@validex/core'
// ✅ This tree-shakes correctly
import { Email, Password, Username } from '@validex/core'
// ✅ Even better: explicit imports ensure bundlers optimize
import Email from '@validex/core/rules/email'
import Password from '@validex/core/rules/password'
import Username from '@validex/core/rules/username'
With proper imports, your bundle only includes the rules you use, plus Zod itself. The blocklists and validation data load only when those specific validators run.
Error Handling in Your API and UI
Here's how to handle Validex errors consistently:
// In your API handler
app.post('/register', async (req, res) => {
const result = registrationSchema.safeParse(req.body)
if (!result.success) {
const errors = result.error.errors.map(error => ({
field: error.path.join('.'),
code: error.code, // e.g., "validation.messages.email.disposableBlocked"
message: error.message, // Localized string
params: error.params, // Interpolation params if needed
}))
return res.status(400).json({ errors })
}
const { email, password, username } = result.data
// Process validated data
})
// In your React component
function RegistrationForm() {
const [errors, setErrors] = useState({})
const handleSubmit = async (formData) => {
const response = await fetch('/register', {
method: 'POST',
body: JSON.stringify(formData),
})
if (!response.ok) {
const { errors } = await response.json()
// Map error codes to user-friendly messages
const errorMap = {
'validation.messages.email.disposableBlocked':
'Please use a business email address',
'validation.messages.password.minLength':
'Password must be at least 10 characters',
'validation.messages.username.taken':
'This username is already taken',
}
const displayErrors = errors.reduce((acc, error) => {
acc[error.field] = errorMap[error.code] || error.message
return acc
}, {})
setErrors(displayErrors)
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" />
{errors.email && <span>{errors.email}</span>}
{/* More fields... */}
</form>
)
}
When to Use Validex vs.
Want This Automated for Your Business?
I build custom AI bots, automation pipelines, and trading systems that run 24/7 and generate revenue on autopilot.
Hire me on Fiverr — AI bots, web scrapers, data pipelines, and automation built to your spec.
Browse my templates on Gumroad — ready-to-deploy bot templates, automation scripts, and AI toolkits.
Recommended Resources
If you want to go deeper on the topics covered in this article:
Some links above are affiliate links — they help support this content at no extra cost to you.
Top comments (0)