Type-safe validation rules built on Zod — tree-shakeable, so you only ship what you use. The main package is @validex/core with optional adapters @validex/nuxt and @validex/fastify.
- Install
- Quick Start
- Why validex?
- Rules — all 25 rules at a glance
- Bundle Size
- Configuration — global defaults, three-tier merge, preloading
- Cross-Field Validation — sameAs, requiredWhen
- Chainable Methods — checks and transforms on any Zod string
- Check Functions — standalone pure functions
- Error Handling — structured errors, getParams, validate()
- i18n — translations, CLI, 141 error codes
- Custom Rules — createRule, customFn, custom regex
- Framework Adapters — Nuxt, Fastify
- Documentation
Install
pnpm add @validex/core zod # core is required pnpm add @validex/nuxt # optional — Nuxt adapter pnpm add @validex/fastify # optional — Fastify adapter
Quick Start
Single rule
import { Email } from '@validex/core' const schema = Email() schema.parse('hello@example.com') // OK schema.parse('not-an-email') // throws ZodError
Rule with options
import { Password } from '@validex/core' const schema = Password({ length: { min: 10 }, uppercase: { min: 2 }, blockCommon: 'basic', }) schema.parse('ABcdefgh1!') // OK — 10+ chars, 2 uppercase, 1 digit, 1 special
Composed schema with validate()
import { z } from 'zod' import { Email, Password, validate } from '@validex/core' const schema = z.object({ email: Email(), password: Password(), }) const result = await validate(schema, { email: 'user@example.com', password: 'Str0ng!Pass', }) if (result.success) { console.log(result.data) // typed as { email: string; password: string } } else { console.log(result.errors) // { email: ['...'], password: ['...'] } console.log(result.firstErrors) // { email: '...', password: '...' } }
Why validex?
I built validex because I was fed up writing the same validation rules over and over again in every project.
Different teams, different defaults, forgetting what I had configured last time, and ending up with inconsistent behavior across the codebase. Sound familiar?
Validex was created to solve that pain once and for all:
- One config system —
setup()lets you define your defaults globally, per-rule, or per-call. Three-tier merge (built-in defaults → global config → per-call options) so you never repeat yourself again. - One consistent error surface —
validate()always returns the same clean shape — flat errors, nested errors, first-per-field, raw issues. One function, one result, every time. - Every error is validex-owned — no raw Zod messages leak to your users. Every error carries a namespace, code, and label for precise routing.
- 25 production-ready rules covering the fields you actually use: identity, auth, networking, finance, and text.
- Tree-shakeable & lightweight — 5–6 kB Brotli per rule (shared core included). All 25 rules together = 13 kB. Heavy data loads on demand.
- i18n-ready out of the box — key mode,
t()function support, label/message transforms, and a CLI that generates ready-to-translate locale files. - First-class framework adapters — Nuxt and Fastify integrations that feel native.
Stop copy-pasting rules. Get consistent, maintainable validation with sensible defaults — and only ship what you actually use.
Rules
| Rule | Description |
|---|---|
| Email address with domain filtering, plus-alias blocking, and disposable detection | |
| Password | Strength rules: length, casing, digits, specials, consecutive limits, common-password ban |
| PasswordConfirmation | Confirms two password fields match |
| PersonName | Human name with unicode support, word count, and boundary rules |
| BusinessName | Company/organization name with boundary and consecutive limits |
| Phone | International phone via libphonenumber-js |
| Website | URL restricted to http/https with optional www and domain filtering |
| Url | General URL with protocol, TLD, and domain validation |
| Username | Alphanumeric with configurable separators and reserved-word ban |
| Slug | URL-safe slug (lowercase, hyphens, length limits) |
| PostalCode | Country-aware postal/ZIP code |
| LicenseKey | Software license key format (segments, separators, charset) |
| Uuid | UUID v1-v7 validation |
| Jwt | JSON Web Token structure with optional expiry checks |
| DateTime | Date/time string with format and range constraints |
| Token | Generic token validation (hex, base64, nanoid, etc.) |
| Text | Free text with length, word count, content detection, and regex override |
| Country | ISO 3166 country code (alpha-2, alpha-3) |
| Currency | ISO 4217 currency code |
| Color | Hex, RGB, HSL, and named CSS color formats |
| CreditCard | Card number with Luhn check and issuer detection |
| Iban | International Bank Account Number with country patterns |
| VatNumber | EU VAT identification number |
| MacAddress | MAC address (colon, hyphen, and dot notations) |
| IpAddress | IPv4 and IPv6 with optional CIDR notation |
Bundle Size
Every rule shares a ~5 kB core (Brotli). Each additional rule adds 0.1-0.8 kB. Measured with esbuild --splitting + Brotli, excluding zod peer dependency.
| Rule | Initial (Brotli) | On-demand data | Trigger |
|---|---|---|---|
| 5.7 kB | — | — | |
| Password | 5.6 kB | +0.5 kB / +3.8 kB / +35.5 kB | blockCommon: 'basic' / 'moderate' / 'strict' |
| PasswordConfirmation | 5.7 kB | — | — |
| PersonName | 5.7 kB | — | — |
| BusinessName | 5.7 kB | — | — |
| Phone | 5.7 kB | libphonenumber-js | bundled dependency |
| Website | 5.7 kB | — | — |
| Url | 5.6 kB | — | — |
| Username | 5.9 kB | +0.8 kB | blockReserved: true |
| Slug | 5.5 kB | — | — |
| PostalCode | 5.4 kB | postcode-validator | bundled dependency |
| LicenseKey | 5.5 kB | — | — |
| Uuid | 5.3 kB | — | — |
| Jwt | 5.6 kB | — | — |
| DateTime | 5.6 kB | — | — |
| Token | 5.5 kB | — | — |
| Text | 5.5 kB | — | — |
| Country | 5.4 kB | +2.4 kB | First use |
| Currency | 5.4 kB | +0.3 kB | First use |
| Color | 5.5 kB | — | — |
| CreditCard | 5.6 kB | +0.3 kB | First use |
| Iban | 5.5 kB | +0.7 kB | First use |
| VatNumber | 5.5 kB | +0.3 kB | First use |
| MacAddress | 5.3 kB | — | — |
| IpAddress | 5.6 kB | — | — |
| Combination | Initial (Brotli) |
|---|---|
| Email + Password | 6.0 kB |
| Form (Email + Password + PersonName + Phone) | 6.9 kB |
| All 25 rules | 13.0 kB |
"On-demand data" loads asynchronously on first use or when the listed option is enabled. Not included in the initial bundle.
Configuration
Global defaults with setup()
import { setup, Email, Password } from '@validex/core' setup({ rules: { email: { blockDisposable: true }, password: { length: { min: 10 }, special: { min: 2 } }, }, i18n: { enabled: true, t: (key, params) => translate(key, params), }, }) // Rules now use your defaults — no need to pass options every time const emailSchema = Email() const passwordSchema = Password()
Three-tier merge
built-in defaults < setup() config < per-call options
Per-call options override setup() config, which overrides built-in defaults. Passing undefined for a per-call option removes the global setting for that field.
import { setup, Email } from '@validex/core' setup({ rules: { email: { blockDisposable: true } } }) Email() // blockDisposable: true (from setup) Email({ blockPlusAlias: true }) // blockDisposable: true + blockPlusAlias: true Email({ blockDisposable: undefined }) // blockDisposable removed for this call
resetConfig()
import { resetConfig } from '@validex/core' resetConfig() // resets to built-in defaults
preloadData()
Preload async data files at startup so first validation has no delay:
import { preloadData } from '@validex/core' await preloadData({ disposable: true, passwords: 'moderate', reserved: true, phone: 'mobile', countryCodes: true, currencyCodes: true, ibanPatterns: true, vatPatterns: true, creditCardPrefixes: true, postalCodes: true, })
Cross-Field Validation
sameAs
Creates a superRefine callback that verifies two fields hold the same value:
import { z } from 'zod' import { Password, sameAs } from '@validex/core' const schema = z.object({ password: Password(), confirmPassword: z.string(), }).superRefine(sameAs('confirmPassword', 'password', { message: 'Passwords do not match', }))
PasswordConfirmation auto-wires this — it registers a sameAs: 'password' constraint automatically:
import { z } from 'zod' import { Password, PasswordConfirmation, validate } from '@validex/core' const schema = z.object({ password: Password(), confirmPassword: PasswordConfirmation(), }) const result = await validate(schema, { password: 'Str0ng!Pass', confirmPassword: 'different', }) // result.firstErrors.confirmPassword → "Password Confirmation must match Password"
requiredWhen
Creates a superRefine callback that marks a field as required when a condition is met:
import { z } from 'zod' import { requiredWhen } from '@validex/core' const schema = z.object({ accountType: z.string(), companyName: z.string().optional(), }).superRefine(requiredWhen( 'companyName', (data) => data['accountType'] === 'business', { message: 'Company name is required for business accounts' }, ))
validate() resolves cross-field
schema.safeParse() only runs field-level validation. validate() adds cross-field checks (sameAs, requiredWhen) after Zod parsing:
// safeParse — field-level only, no cross-field const zodResult = schema.safeParse(data) // validate — runs field-level + cross-field const result = await validate(schema, data)
Chainable Methods
Import @validex/core and all Zod schemas get these methods (intended for use on string schemas):
Checks (return same type, add refinement)
| Method | Options | Description |
|---|---|---|
.hasUppercase(opts?) |
min?, max? |
Requires uppercase letters |
.hasLowercase(opts?) |
min?, max? |
Requires lowercase letters |
.hasDigits(opts?) |
min?, max? |
Requires digits |
.hasSpecial(opts?) |
min?, max? |
Requires special characters |
.noEmails(opts?) |
— | Blocks email addresses |
.noUrls(opts?) |
— | Blocks URLs |
.noHtml(opts?) |
— | Blocks HTML tags |
.noPhoneNumbers(opts?) |
— | Blocks phone numbers |
.noSpaces(opts?) |
— | Blocks whitespace |
.onlyAlpha(opts?) |
— | Letters only |
.onlyNumeric(opts?) |
— | Digits only |
.onlyAlphanumeric(opts?) |
— | Letters + digits |
.onlyAlphaSpaceHyphen(opts?) |
— | Letters, spaces, hyphens |
.onlyAlphanumericSpaceHyphen(opts?) |
— | Letters, digits, spaces, hyphens |
.maxWords(opts) |
max |
Maximum word count |
.minWords(opts) |
min |
Minimum word count |
.maxConsecutive(opts) |
max |
Max consecutive identical chars |
Transforms (return ZodPipe)
| Method | Description |
|---|---|
.toTitleCase() |
Converts to Title Case |
.toSlug() |
Converts to URL-safe slug |
.stripHtml() |
Removes HTML tags |
.collapseWhitespace() |
Collapses multiple spaces to single |
.emptyToUndefined() |
Converts "" to undefined |
import { z } from 'zod' import '@validex/core' const schema = z.string().hasUppercase({ min: 2 }).noSpaces().toSlug()
Check Functions
Pure functions, no Zod dependency. Import from @validex/core/checks.
Composition
| Function | Signature | Description |
|---|---|---|
hasUppercase |
(value: string, min: number, max?: number) => boolean |
Uppercase letter count within [min, max] |
hasLowercase |
(value: string, min: number, max?: number) => boolean |
Lowercase letter count within [min, max] |
hasDigits |
(value: string, min: number, max?: number) => boolean |
Digit count within [min, max] |
hasSpecial |
(value: string, min: number, max?: number) => boolean |
Special character count within [min, max] |
Detection
| Function | Signature | Description |
|---|---|---|
containsEmail |
(value: string) => boolean |
Detects email-like patterns |
containsUrl |
(value: string) => boolean |
Detects URL-like patterns |
containsHtml |
(value: string) => boolean |
Detects HTML tags |
containsPhoneNumber |
(value: string) => Promise<boolean> |
Detects phone numbers (async, uses libphonenumber-js) |
Restriction
| Function | Signature | Description |
|---|---|---|
onlyAlpha |
(value: string) => boolean |
Every character is a unicode letter |
onlyNumeric |
(value: string) => boolean |
Every character is a digit |
onlyAlphanumeric |
(value: string) => boolean |
Every character is a letter or digit |
onlyAlphaSpaceHyphen |
(value: string) => boolean |
Letters, spaces, hyphens only |
onlyAlphanumericSpaceHyphen |
(value: string) => boolean |
Letters, digits, spaces, hyphens only |
Limits
| Function | Signature | Description |
|---|---|---|
maxWords |
(value: string, max: number) => boolean |
At most max words |
minWords |
(value: string, min: number) => boolean |
At least min words |
maxConsecutive |
(value: string, max: number) => boolean |
No character repeats more than max times |
noSpaces |
(value: string) => boolean |
No whitespace characters |
Transforms
| Function | Signature | Description |
|---|---|---|
emptyToUndefined |
(value: unknown) => unknown |
"" and null to undefined |
toTitleCase |
(value: string) => string |
Title Case with hyphen/apostrophe handling |
toSlug |
(value: string) => string |
URL-safe slug |
stripHtml |
(value: string) => string |
Removes HTML tags |
collapseWhitespace |
(value: string) => string |
Collapses whitespace, trims |
import { hasUppercase, containsEmail, toSlug } from '@validex/core/checks' hasUppercase('Hello', 1) // true containsEmail('hi@test.com') // true toSlug('Hello World!') // 'hello-world'
Error Handling
Error structure
Every validex error carries structured metadata via Zod's custom error params:
ctx.addIssue({ code: 'custom', params: { code: 'disposableBlocked', namespace: 'email', label: 'Email', domain: 'tempmail.com', }, })
getParams(issue)
Extract structured metadata from any Zod issue:
import { Email, getParams } from '@validex/core' const schema = Email() const result = schema.safeParse('user@tempmail.com') if (!result.success) { const params = getParams(result.error.issues[0]) // { code: 'disposableBlocked', namespace: 'email', label: 'Email', // key: 'validation.messages.email.disposableBlocked', path: [], ... } }
Error code pattern
Keys follow: validation.messages.{namespace}.{code}
validation.messages.email.disposableBlockedvalidation.messages.password.commonBlockedvalidation.messages.username.reservedBlocked
validate() result
interface ValidationResult<T> { readonly success: boolean readonly data?: T // typed parsed data (when success) readonly errors: Record<string, readonly string[]> // dot-path to all messages readonly firstErrors: Record<string, string> // dot-path to first message readonly nestedErrors: NestedErrors // nested object matching schema shape readonly issues: ReadonlyArray<unknown> // raw Zod issues (escape hatch) }
i18n
Setup
import { setup } from '@validex/core' setup({ i18n: { enabled: true, prefix: 'validation', // default separator: '.', // default t: (key, params) => i18next.t(key, params), }, })
Key pattern
validation.messages.{namespace}.{code}
When i18n.enabled is true and t() is provided, validex calls t() automatically for every error message and field label.
Label transforms
setup({ label: { fallback: 'derived', // 'derived' | 'generic' | 'none' transform: ({ path, fieldName, defaultLabel }) => { return myLabelLookup(fieldName) ?? defaultLabel }, }, })
CLI
npx validex fr de --output ./locales npx validex ja --empty --output ./locales
Full guide with all 141 error codes: Translation Guide
Custom Rules
createRule()
import { createRule } from '@validex/core' import { z } from 'zod' interface HexColorOptions { label?: string emptyToUndefined?: boolean normalize?: boolean customFn?: (value: string) => true | string | Promise<true | string> allowAlpha?: boolean } const HexColor = createRule<HexColorOptions>({ name: 'hexColor', defaults: { allowAlpha: false }, build: (opts) => { const pattern = opts.allowAlpha ? /^#[\da-f]{6,8}$/i : /^#[\da-f]{6}$/i return z.string().regex(pattern) }, messages: { invalid: '{{label}} is not a valid hex color', }, }) const schema = HexColor({ allowAlpha: true }) schema.parse('#ff00aacc') // OK
customFn
Every rule accepts a customFn that runs after built-in checks. Return true to pass or a string to fail:
import { Email } from '@validex/core' const schema = Email({ customFn: (value) => value.endsWith('.org') || 'Must be a .org domain', }) schema.parse('info@example.org') // OK schema.parse('info@example.com') // throws — "Must be a .org domain"
Custom regex
Rules that extend FormatRuleOptions (like Text) accept a regex property:
import { Text } from '@validex/core' const schema = Text({ regex: /^[^<>]+$/, })
Full reference: API Reference
Framework Adapters
Nuxt
pnpm add @validex/nuxt
// nuxt.config.ts export default defineNuxtConfig({ modules: ['@validex/nuxt'], validex: { rules: { email: { blockDisposable: true } }, }, })
// In a component — useValidation is auto-imported const { validate, errors, firstErrors, isValid } = useValidation(schema)
Full guide: @validex/nuxt README
Fastify
pnpm add @validex/fastify
import Fastify from 'fastify' import { validexPlugin } from '@validex/fastify' const app = Fastify() await app.register(validexPlugin, { rules: { email: { blockDisposable: true } }, })
Full guide: @validex/fastify README
Documentation
| Resource | Description |
|---|---|
| API Reference | Every rule, option, default, error code |
| Translation Guide | i18n setup, CLI, all 141 error codes |
| Contributing | Dev setup, conventions, PR workflow |