@validex/core
v1.0.3
Published
Type-safe validation rules built on Zod
Maintainers
Readme
@validex/core
Type-safe validation rules built on Zod — tree-shakeable, so you only ship what you use.
- Why validex?
- Install
- Quick Start
- Rules — all 25 rules
- Bundle Size
- Configuration — global defaults, three-tier merge, preloading
- Cross-Field Validation — sameAs, requiredWhen
- Chainable Methods — checks and transforms
- Check Functions — standalone pure functions
- Error Handling — structured errors, getParams
- i18n — translations, CLI
- Custom Rules — createRule, customFn
- Framework Adapters — Nuxt, Fastify
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.
Install
pnpm add @validex/core zodQuick Start
Single rule
import { Email } from '@validex/core'
const schema = Email()
schema.parse('[email protected]') // OK
schema.parse('not-an-email') // throws ZodErrorRule 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 specialComposed 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: '[email protected]',
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: '...' }
}Rules
| Rule | Description | |------|-------------| | Email | 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 |
|------|-----------------|----------------|---------|
| Email | 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 | external | libphonenumber-js (bundled) |
| 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 | external | postcode-validator (bundled) |
| 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 optionsPer-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 callresetConfig()
import { resetConfig } from '@validex/core'
resetConfig() // resets to built-in defaultspreloadData()
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('[email protected]') // 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('[email protected]')
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 ./localesFull 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') // OKcustomFn
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('[email protected]') // OK
schema.parse('[email protected]') // 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
- @validex/nuxt — Nuxt module with auto-imports and
useValidationcomposable - @validex/fastify — Fastify plugin with request validation decorators
