Every project starts the same way. You set up Zod, define your form schema, and then spend the next hour writing .refine() chains for fields you've validated a hundred times before.
Email with disposable domain blocking. Password with uppercase, digits, special characters and length. Phone numbers that need to be E.164. Usernames that can't be "admin" or "root". IBAN that passes the mod-97 check.
You've written all of these before. So have I. At some point I got tired of copy-pasting the same 40-line refinement block into every new project, so I extracted them into a library.
Here are the 5 I was rewriting most often, what they look like in raw Zod, and what I replaced them with.
1. Email validation (with disposable domain blocking)
The basic check is easy. The edge cases aren't.
// raw Zod
const email = z.string()
.email()
.refine(val => !val.endsWith('+'), 'No plus aliases')
.refine(val => {
const domain = val.split('@')[1]
return !disposableDomains.includes(domain)
}, 'Disposable email not allowed')
Where does disposableDomains come from? You either maintain a list yourself, pull one from npm, or skip it entirely because it's too much hassle. And you still haven't handled plus-aliasing properly.
// validex
const email = Email({
blockDisposable: true,
blockPlusAlias: true
})
One line. Disposable list is built in via disposable-email-domains-js. Plus-alias detection included. Returns a standard Zod schema. Error codes: email.invalid, email.disposableBlocked, email.plusAliasBlocked.
2. Password strength
This one always balloons. Every form has slightly different requirements, and every .refine() produces the same generic "custom" error code.
// raw Zod
const password = z.string()
.min(8, 'At least 8 characters')
.max(64, 'Max 64 characters')
.refine(val => /[A-Z]/.test(val), 'Need an uppercase letter')
.refine(val => /[a-z]/.test(val), 'Need a lowercase letter')
.refine(val => /[0-9]/.test(val), 'Need a digit')
.refine(val => /[^A-Za-z0-9]/.test(val), 'Need a special character')
.refine(val => !/(.)\1{3,}/.test(val), 'No more than 3 consecutive repeated characters')
7 lines. Every error is code: "custom". Good luck building a password strength meter from that.
// validex
const password = Password({
length: { min: 8, max: 64 },
uppercase: { min: 1 },
lowercase: { min: 1 },
digits: { min: 1 },
special: { min: 1 },
consecutive: { max: 3 }
})
Same validation. But each check has its own error code: password.minUppercase, password.minLowercase, password.minDigits, password.minSpecial, password.maxConsecutive. You can map those directly to UI feedback or i18n keys without parsing strings.
Also supports blockCommon with three tiers: 'basic' (top 100), 'moderate' (top 1,000), or 'strict' (top 10,000 passwords from real breach data).
3. Phone numbers
Phone validation is deceptively hard. Most devs reach for a regex and hope for the best.
// raw Zod
const phone = z.string()
.refine(val => /^\+[1-9]\d{6,14}$/.test(val), 'Invalid E.164 format')
This passes +1234567, which is technically E.164 compliant but not a real phone number. It also rejects valid numbers with spaces or dashes that users naturally type.
// validex
const phone = Phone()
Validates via libphonenumber-js under the hood. Normalises output to E.164 by default. You can also restrict by country, require mobile numbers, or require a country code prefix. Error code: phone.invalid.
4. Username
Looks simple until you realise you need to block reserved words, check character sets, and handle edge cases like consecutive dots or leading underscores.
// raw Zod
const username = z.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores')
.refine(val => !['admin', 'root', 'system', 'moderator', 'null', 'undefined']
.includes(val.toLowerCase()), 'Reserved username')
Your reserved list is always incomplete. You add to it every time someone signs up as "administrator" or "support".
// validex
const username = Username({
length: { min: 3, max: 20 },
blockReserved: true
})
Built-in reserved word list. Error codes: username.invalid, username.reservedBlocked, username.boundary.
5. IBAN
This one is wild. Most devs skip it or use a separate library just for this field.
// raw Zod - good luck
const iban = z.string()
.refine(val => {
const rearranged = val.slice(4) + val.slice(0, 4)
const numeric = rearranged.replace(/[A-Z]/g, c =>
(c.charCodeAt(0) - 55).toString()
)
let remainder = ''
for (const char of numeric) {
remainder = String(Number(remainder + char) % 97)
}
return Number(remainder) === 1
}, 'Invalid IBAN')
That's the mod-97 check. You still need to validate country code, length per country, and format.
// validex
const iban = Iban()
One line. Mod-97 check, country-specific length and format validation. Error codes: iban.invalid, iban.countryBlocked, iban.countryNotAllowed.
The pattern
Every rule returns a standard Zod schema. You compose them with z.object() like anything else:
import { z } from 'zod'
import { Email, Password, Username, Phone, Iban } from '@validex/core'
const schema = z.object({
email: Email({ blockDisposable: true }),
password: Password({ length: { min: 8 }, uppercase: { min: 1 } }),
username: Username({ blockReserved: true }),
phone: Phone(),
iban: Iban()
})
Every field has structured error codes you can use for per-rule UI feedback or i18n without string parsing. There are 25 rules total covering these and more.
pnpm add @validex/core zod
GitHub: github.com/chiptoma/validex
npm: @validex/core
Disclosure: I'm the author. If you've been copy-pasting the same refinements across projects, this is what I built to stop doing that.
Top comments (0)