Every project I start ends up with the same validation code.
Email with disposable domain blocking. Password with strength rules. Phone number via libphonenumber-js. Username with reserved word checks. All built with .refine() and .superRefine() chains on top of Zod.
Different projects, different defaults, forgotten edge cases. The same bugs are showing up in different codebases because I copied the wrong version of the email regex.
Here's what a typical registration form looked like before:
const schema = z.object({
email: z
.string()
.email()
.refine(
async (val) => {
const domain = val.split('@')[1]
const list = await loadDisposableDomains()
return !list.includes(domain)
},
'Disposable emails not allowed'
)
.refine((val) => !val.includes('+'), 'Plus aliases not allowed'),
password: z
.string()
.min(8)
.max(128)
.refine((val) => /[A-Z]/.test(val), 'Needs uppercase')
.refine((val) => /[a-z]/.test(val), 'Needs lowercase')
.refine((val) => /\d/.test(val), 'Needs digit')
.refine((val) => /[^A-Za-z0-9]/.test(val), 'Needs special character')
.refine(
(val) => !/(.)\1{3,}/.test(val),
'Too many consecutive characters'
)
.refine(async (val) => {
const common = await loadCommonPasswords()
return !common.includes(val.toLowerCase())
}, 'Too common'),
username: z
.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers, underscores only')
.refine((val) => /^[a-zA-Z0-9]/.test(val), 'Must start with letter or number')
.refine((val) => /[a-zA-Z0-9]$/.test(val), 'Must end with letter or number')
.refine(async (val) => {
const reserved = await loadReservedWords()
return !reserved.includes(val.toLowerCase())
}, 'Username is reserved'),
phone: z.string().refine(async (val) => {
const { isValidPhoneNumber } = await import('libphonenumber-js')
return isValidPhoneNumber(val)
}, 'Invalid phone number'),
})
That's around 40 lines for four fields. Across a codebase with multiple forms, it adds up fast. And every .refine() produces a generic Zod error with no structured metadata. Good luck building i18n on top of that.
After
I built validex to stop rewriting this. It's a layer on top of Zod 4 with 25 typed validation rules.
Same form:
import { z } from 'zod'
import { Email, Password, Username, Phone, validate } from '@validex/core'
const schema = z.object({
email: Email({ blockDisposable: true, blockPlusAlias: true }),
password: Password({ length: { min: 8 }, consecutive: { max: 3 }, blockCommon: 'basic' }),
username: Username({ blockReserved: true }),
phone: Phone({ format: 'e164' }),
})
const result = await validate(schema, formData)
12 lines. Same validation. Every rule returns a standard Zod schema, so it composes with z.object() like anything else.
What this gives you
Structured errors, not strings. Every error carries a namespace, code, and label. Not just "Invalid email" but { namespace: 'email', code: 'disposableBlocked', label: 'Email', domain: 'tempmail.com' }. 141 error codes across 27 namespaces.
Three-tier config merge. Set defaults once with setup(), override per-call when needed:
import { setup, Email } from '@validex/core'
setup({
rules: {
email: { blockDisposable: true },
password: { length: { min: 10 }, special: { min: 2 } },
},
})
// Every Email() call now blocks disposable domains by default
const schema = z.object({
email: Email(), // uses setup() defaults
adminEmail: Email({ blockPlusAlias: true }), // adds on top of defaults
})
Built-in defaults, then setup() globals, then per-call options. Three layers, deepmerged.
Heavy data loads on demand. Disposable email domains (~30 kB), common password lists (100 to 10K entries), phone metadata via libphonenumber-js. None of it hits your initial bundle. It loads async on first use, or you can preload at startup:
await preloadData({ disposable: true, passwords: 'strict', phone: 'mobile' })
After preload, everything works synchronously with .parse().
i18n that works. Every error code maps to validation.messages.{namespace}.{code}. Hook up your t() function and validex calls it for every error message:
setup({
i18n: {
enabled: true,
t: (key, params) => i18next.t(key, params),
},
})
CLI generates translation files:
npx validex fr de es --output ./locales
Bundle size
13 kB Brotli for all 25 rules. Import just one and you ship around 5.5 kB (shared core included). Heavy datasets load async and are not in your initial bundle.
| What you import | Brotli |
|---|---|
| Email only | 5.7 kB |
| Email + Password | 6.0 kB |
| Full form (Email + Password + PersonName + Phone) | 6.9 kB |
| All 25 rules | 13.0 kB |
The 25 rules
Email, Password, PasswordConfirmation, PersonName, BusinessName, Phone, Website, Url, Username, Slug, PostalCode, LicenseKey, Uuid, Jwt, DateTime, Token, Text, Country, Currency, Color, CreditCard, Iban, VatNumber, MacAddress, IpAddress.
Each one has typed options, sensible defaults and structured error codes.
Framework adapters
Nuxt:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@validex/nuxt'],
validex: { rules: { email: { blockDisposable: true } } },
})
// Component - useValidation is auto-imported
const { validate, errors, firstErrors, isValid } = useValidation(schema)
Fastify:
await app.register(validexPlugin, {
rules: { email: { blockDisposable: true } },
})
app.post('/login', async (request, reply) => {
const result = await request.validate(schema, { source: 'body' })
if (!result.success) return reply.status(400).send(result.firstErrors)
return { ok: true }
})
Try it
pnpm add @validex/core zod
GitHub: github.com/chiptoma/validex
npm: @validex/core
Docs: API Reference | Translation Guide
If you've been copy-pasting the same .refine() chains across projects, this is what I built to stop doing that.
Open to feedback on what rules to add next.
Top comments (0)