Validation
Stacks.js includes ts-validation, a lightweight, type-safe validation library with a fluent chainable API. It provides blazing-fast performance, comprehensive error reporting, and full TypeScript support.
Protocol context — This guide covers the Stacks.js implementation of the draft Validation contract. The fluent APIs shown here are implementation-specific.
Overview
import { v } from '@stacksjs/validation'
// Create a schema validator
const userSchema = v.object({
name: v.string().min(2).max(100).required(),
email: v.string().email().required(),
password: v.string().min(8).matches(/[A-Z]/, 'Must contain uppercase').required(),
role: v.enum(['user', 'admin', 'moderator']).required(),
age: v.integer().min(18).max(120).optional(),
preferences: v.object({
newsletter: v.boolean(),
theme: v.enum(['light', 'dark']),
}).optional(),
})
// Validate data
const result = userSchema.validate(userData)
if (result.valid) {
console.log('User is valid!')
} else {
console.error('Validation errors:', result.errors)
}
Schema Types
String Validation
import { v } from '@stacksjs/validation'
// Basic string validation
const nameValidator = v.string().min(2).max(50).required()
// Email validation
const emailValidator = v.string().email().required()
// URL validation
const websiteValidator = v.string().url().optional()
// Pattern matching with regex
const zipCodeValidator = v.string().matches(/^\d{5}$/).required()
const phoneValidator = v.string().matches(/^\+?[\d\s-()]+$/).required()
// Character restrictions
const usernameValidator = v.string().alphanumeric().required()
const codeValidator = v.string().alpha().required()
const numericStringValidator = v.string().numeric().required()
// Exact length
const countryCodeValidator = v.string().length(2).required()
// Text (specialized for longer content)
const bioValidator = v.text().max(500).optional()
// Equality check
const confirmEmail = v.string().equals(email).required()
Number Validation
// Basic number validation
const ageValidator = v.number().min(18).max(120).required()
// Integer validation (whole numbers only)
const quantityValidator = v.integer().min(1).required()
// Positive/negative numbers
const positiveValidator = v.integer().positive().required()
const temperatureValidator = v.number().negative().optional()
// Float validation (decimal numbers)
const priceValidator = v.float().min(0.01).required()
// Decimal with precision
const ratingValidator = v.decimal().min(0).max(5).required()
// Small integer (-32,768 to 32,767)
const smallNumberValidator = v.smallint().required()
Boolean Validation
// Simple boolean
const activeValidator = v.boolean().required()
// With default (in object context)
const termsAcceptedValidator = v.boolean().required()
Array Validation
// Basic array
const tagsValidator = v.array().min(1).max(10).required()
// Validate each item in the array
const numbersValidator = v.array().each(v.number().positive()).required()
// Array of strings with constraints
const emailsValidator = v.array().each(v.string().email()).min(1).required()
// Fixed length array
const coordinatesValidator = v.array().length(2).each(v.number()).required()
// Array of objects
const itemsValidator = v.array().each(
v.object({
name: v.string().required(),
quantity: v.integer().min(1).required(),
price: v.float().min(0).required(),
})
).required()
Object Validation
// Basic object schema
const addressValidator = v.object({
street: v.string().required(),
city: v.string().required(),
state: v.string().length(2).required(),
zip: v.string().matches(/^\d{5}$/).required(),
country: v.string().optional(),
})
// Nested objects
const userValidator = v.object({
name: v.string().min(2).required(),
email: v.string().email().required(),
address: addressValidator, // Reuse validators
billing: v.object({
cardLast4: v.string().length(4).required(),
expiryMonth: v.integer().min(1).max(12).required(),
expiryYear: v.integer().min(2024).required(),
}).optional(),
})
// Strict mode (no extra fields allowed)
const strictValidator = v.object().strict().shape({
id: v.number().required(),
name: v.string().required(),
})
Enum Validation
// String enum
const statusValidator = v.enum(['active', 'inactive', 'pending']).required()
// Role enum
const roleValidator = v.enum(['user', 'admin', 'moderator', 'guest']).required()
// With optional
const priorityValidator = v.enum(['low', 'medium', 'high', 'critical']).optional()
Date and Time Validation
// Basic date validation
const dateValidator = v.date()
dateValidator.test(new Date()) // true
dateValidator.test(new Date('invalid')) // false
// Datetime (MySQL DATETIME compatible)
const datetimeValidator = v.datetime()
datetimeValidator.test(new Date('2024-01-01')) // true
// Time (24-hour format)
const timeValidator = v.time()
timeValidator.test('14:30') // true
timeValidator.test('25:00') // false (invalid hour)
// Unix timestamp
const unixValidator = v.unix()
unixValidator.test(1683912345) // true (seconds)
unixValidator.test(1683912345000) // true (milliseconds)
// Timestamp (MySQL TIMESTAMP compatible)
const timestampValidator = v.timestamp()
// Timestamp with timezone
const timestampTzValidator = v.timestampTz()
Password Validation
// Comprehensive password validation
const passwordValidator = v.password()
.min(8)
.max(128)
.alphanumeric()
.hasUppercase()
.hasLowercase()
.hasNumbers()
.hasSpecialCharacters()
const result = passwordValidator.validate('MySecureP@ss123')
// Password confirmation matching
const confirmPasswordValidator = v.password().matches('MySecureP@ss123')
// Custom password rules
const customPasswordValidator = v.string()
.min(8, 'Password must be at least 8 characters')
.matches(/[A-Z]/, 'Must contain at least one uppercase letter')
.matches(/[a-z]/, 'Must contain at least one lowercase letter')
.matches(/\d/, 'Must contain at least one number')
.matches(/[^A-Za-z0-9]/, 'Must contain at least one special character')
.required()
JSON Validation
// JSON string validation
const jsonValidator = v.json()
jsonValidator.test('{"name": "John"}') // true
jsonValidator.test('{"a": 1, "b": 2}') // true
jsonValidator.test('123') // false (primitive)
jsonValidator.test('not json') // false
BigInt Validation
// BigInt validation with range
const bigIntValidator = v.bigint().min(0n).max(1000000n).required()
Binary and Blob Validation
// Binary data
const binaryValidator = v.binary()
// Blob validation
const blobValidator = v.blob()
Custom Validation
Inline Custom Rules
// Simple custom validator
const isEven = (val: number) => val % 2 === 0
const evenNumberValidator = v.custom(isEven, 'Number must be even')
// Custom string validation
const noSpacesValidator = v.string()
.custom((value) => !value.includes(' '), 'Spaces are not allowed')
.required()
// Complex validation with regex
const slugValidator = v.string()
.matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid slug format')
.required()
Custom Error Messages
const userValidator = v.object({
name: v.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name cannot exceed 50 characters')
.required('Name is required'),
email: v.string()
.email('Please provide a valid email address')
.required('Email is required'),
age: v.integer()
.min(18, 'You must be at least 18 years old')
.max(120, 'Invalid age')
.required('Age is required'),
})
Conditional Validation
const userValidator = v.object({
name: v.string().required(),
email: v.string().email().required(),
age: v.integer().min(0).required(),
// Conditional: guardian info required if under 18
guardianInfo: v.object({
name: v.string().required(),
phone: v.string().required(),
}).custom((value, data) => {
// Required if user is under 18
return data.age >= 18 || value !== null
}, 'Guardian information required for users under 18'),
})
Validation Methods
validate() - Full Validation
const emailValidator = v.string().email().required()
const result = emailValidator.validate('john@example.com')
if (result.valid) {
console.log('Valid!')
} else {
// Access error messages
result.errors.forEach((error) => {
console.log(error.message)
})
}
test() - Quick Boolean Check
const emailValidator = v.string().email().required()
// Returns boolean - quick check
const isValid = emailValidator.test('john@example.com') // true
const isInvalid = emailValidator.test('invalid') // false
Object Validation Results
const userValidator = v.object({
name: v.string().required(),
email: v.string().email().required(),
})
const result = userValidator.validate({
name: '',
email: 'invalid-email',
})
if (!result.valid) {
// Errors are organized by field
Object.entries(result.errors).forEach(([field, errors]) => {
console.log(`${field}:`)
errors.forEach((error) => {
console.log(` - ${error.message}`)
})
})
}
// Output:
// name:
// - Must be at least 1 character long
// email:
// - Must be a valid email address
Reusable Validators
Creating Reusable Schemas
import { v } from '@stacksjs/validation'
// Define reusable validators
const emailValidator = v.string().email().required()
const phoneValidator = v.string().matches(/^\+?[\d\s-()]+$/).required()
const passwordValidator = v.password()
.min(8)
.hasUppercase()
.hasLowercase()
.hasNumbers()
.required()
// Reuse in different schemas
const contactFormValidator = v.object({
email: emailValidator,
phone: phoneValidator,
message: v.text().min(10).max(1000).required(),
})
const registrationValidator = v.object({
email: emailValidator,
password: passwordValidator,
confirmPassword: passwordValidator,
})
const profileValidator = v.object({
primaryEmail: emailValidator,
secondaryEmail: emailValidator.optional(), // Make optional
phone: phoneValidator.optional(),
})
Schema Composition
// Address schema (reusable)
const addressSchema = v.object({
street: v.string().required(),
city: v.string().required(),
state: v.string().length(2).required(),
zipCode: v.string().matches(/^\d{5}(-\d{4})?$/).required(),
})
// User schema with address
const userSchema = v.object({
name: v.string().min(2).required(),
email: v.string().email().required(),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
})
// Order schema
const orderSchema = v.object({
userId: v.integer().required(),
items: v.array().each(
v.object({
productId: v.integer().required(),
quantity: v.integer().min(1).required(),
})
).min(1).required(),
shippingAddress: addressSchema,
})
Controller Integration
Form Request Validation
// app/Requests/CreateUserRequest.ts
import { v, FormRequest } from '@stacksjs/validation'
export default class CreateUserRequest extends FormRequest {
// Define validation schema
schema() {
return v.object({
name: v.string().min(2).max(100).required(),
email: v.string().email().required(),
password: v.password()
.min(8)
.hasUppercase()
.hasNumbers()
.required(),
role: v.enum(['user', 'admin']).optional(),
})
}
// Authorization check
authorize() {
return true
}
// Prepare data before validation
prepareForValidation() {
this.merge({
email: this.email?.toLowerCase().trim(),
name: this.name?.trim(),
})
}
}
Using in Controllers
// app/Controllers/UserController.ts
export default class UserController extends Controller {
async store(request: CreateUserRequest) {
// Request is already validated
const user = await User.create(request.validated())
return response(user, 201)
}
// Or inline validation
async update(request: Request) {
const schema = v.object({
name: v.string().min(2).optional(),
email: v.string().email().optional(),
})
const result = schema.validate(request.body)
if (!result.valid) {
return response({ errors: result.errors }, 422)
}
const user = await User.find(request.params.id)
await user.update(request.body)
return response(user)
}
}
API Validation
Request Validation Middleware
// middleware/ValidateRequest.ts
import { v } from '@stacksjs/validation'
export function validateRequest(schema: ReturnType<typeof v.object>) {
return async (request: Request, next: Function) => {
const result = schema.validate(request.body)
if (!result.valid) {
return response({
message: 'Validation failed',
errors: result.errors,
}, 422)
}
request.validated = request.body
return next()
}
}
// Usage in routes
const createUserSchema = v.object({
name: v.string().min(2).required(),
email: v.string().email().required(),
})
router.post('/api/users',
validateRequest(createUserSchema),
UserController.store
)
Real-time Field Validation
// API endpoint for field validation
router.post('/api/validate/:field', async (request) => {
const { field } = request.params
const value = request.body[field]
const validators: Record<string, any> = {
email: v.string().email().required(),
username: v.string().alphanumeric().min(3).max(20).required(),
password: v.password().min(8).hasUppercase().hasNumbers().required(),
}
const validator = validators[field]
if (!validator) {
return response({ valid: true })
}
const result = validator.validate(value)
return response({
valid: result.valid,
errors: result.valid ? [] : result.errors.map(e => e.message),
})
})
Model Validation
Automatic Model Validation
// app/Models/User.ts
import { v } from '@stacksjs/validation'
export default class User extends Model {
static fields = {
name: { type: 'string', required: true },
email: { type: 'string', unique: true },
password: { type: 'string' },
role: { type: 'enum', options: ['user', 'admin'] },
age: { type: 'integer', nullable: true },
}
// Validation schema
static schema = v.object({
name: v.string().min(2).max(100).required(),
email: v.string().email().required(),
password: v.password().min(8).hasUppercase().required(),
role: v.enum(['user', 'admin']).required(),
age: v.integer().min(0).max(150).optional(),
})
// Enable auto-validation on create/update
static validate = true
}
// Validation runs automatically
try {
await User.create({ name: 'J', email: 'invalid' })
} catch (error) {
console.log(error.errors)
// { name: [...], email: [...] }
}
TypeScript Integration
Type Inference
import { v } from '@stacksjs/validation'
// Define schema
const userSchema = v.object({
name: v.string().required(),
email: v.string().email().required(),
age: v.integer().min(18).optional(),
role: v.enum(['user', 'admin', 'moderator']).required(),
preferences: v.object({
newsletter: v.boolean(),
theme: v.enum(['light', 'dark']),
}).optional(),
})
// Infer type from schema (similar to Zod)
type User = {
name: string
email: string
age?: number
role: 'user' | 'admin' | 'moderator'
preferences?: {
newsletter: boolean
theme: 'light' | 'dark'
}
}
// Use with validation
const result = userSchema.validate(userData)
if (result.valid) {
const user: User = userData
// TypeScript knows user is valid
}
Frontend Integration
Form Validation Component
<template>
<form@submit.prevent="submit
<divclass="
<labelfor="Email</label
<x-input
v-model="form.email"
id="email"
type="email"
:error="errors.email?.[0]?.message"
="validateField('email')"
/>
</div
<divclass="
<labelfor="Password</label
<x-input
v-model="form.password"
id="password"
type="password"
:error="errors.password?.[0]?.message"
="validateField('password')"
/>
</div
<x-buttontype=":loading="submitting
Submit
</x-button
</form
</template
<script>
import { v } from '@stacksjs/validation'
const schema = v.object({
email: v.string().email('Please enter a valid email').required(),
password: v.password().min(8).hasUppercase().hasNumbers().required(),
})
export default {
data() {
return {
form: { email: '', password: '' },
errors: {},
submitting: false,
}
},
methods: {
validateField(field) {
const fieldSchema = {
email: v.string().email().required(),
password: v.password().min(8).hasUppercase().hasNumbers().required(),
}
const result = fieldSchema[field].validate(this.form[field])
this.errors[field] = result.valid ? null : result.errors
},
async submit() {
const result = schema.validate(this.form)
if (!result.valid) {
this.errors = result.errors
return
}
this.submitting = true
try {
await this.$api.post('/register', this.form)
this.$router.push('/dashboard')
} finally {
this.submitting = false
}
},
},
}
</script
Configuration
Global Configuration
// validation.config.ts
import type { ValidationOptions } from '@stacksjs/validation'
const config: ValidationOptions = {
// Enable detailed error messages
verbose: true,
// Stop on first error
strictMode: false,
// Cache validation results
cacheResults: true,
// Custom error messages
errorMessages: {
required: '{field} is required',
email: '{field} must be a valid email address',
min: '{field} must be at least {min} characters',
max: '{field} cannot exceed {max} characters',
matches: '{field} format is invalid',
},
}
export default config
Testing
Testing Validators
import { v } from '@stacksjs/validation'
import { describe, it, expect } from 'bun:test'
describe('User Validation', () => {
const userSchema = v.object({
name: v.string().min(2).required(),
email: v.string().email().required(),
})
it('validates correct user data', () => {
const result = userSchema.validate({
name: 'John Doe',
email: 'john@example.com',
})
expect(result.valid).toBe(true)
})
it('rejects invalid email', () => {
const result = userSchema.validate({
name: 'John Doe',
email: 'invalid-email',
})
expect(result.valid).toBe(false)
expect(result.errors.email).toBeDefined()
})
it('rejects short name', () => {
const result = userSchema.validate({
name: 'J',
email: 'john@example.com',
})
expect(result.valid).toBe(false)
expect(result.errors.name[0].message).toContain('at least 2')
})
it('validates using test() for quick checks', () => {
const emailValidator = v.string().email()
expect(emailValidator.test('valid@email.com')).toBe(true)
expect(emailValidator.test('invalid')).toBe(false)
})
})
Performance Tips
- Reuse validators: Create validators once and reuse them
- Use specific validators: Use
v.integer()instead ofv.number()when possible - Order rules efficiently: Put most likely to fail rules first
- Use
.optional(): Skip validation for undefined optional fields - Cache schemas: Define schemas at module level, not in functions
// Good - schema defined once
const userSchema = v.object({
name: v.string().required(),
email: v.string().email().required(),
})
function validateUser(data: unknown) {
return userSchema.validate(data)
}
// Bad - schema recreated on every call
function validateUser(data: unknown) {
const schema = v.object({
name: v.string().required(),
email: v.string().email().required(),
})
return schema.validate(data)
}
Available Validators
| Type | Description |
|---|---|
v.string() | String validation with min/max/email/url/matches |
v.text() | Text content (longer strings) |
v.number() | Number validation with min/max |
v.integer() | Integer validation |
v.float() | Floating point numbers |
v.decimal() | Decimal precision numbers |
v.smallint() | Small integers (-32,768 to 32,767) |
v.bigint() | BigInt validation |
v.boolean() | Boolean validation |
v.array() | Array validation with each/min/max/length |
v.object() | Object schema validation |
v.enum() | Enum value validation |
v.date() | Date object validation |
v.datetime() | Datetime validation |
v.time() | Time string validation (HH:MM) |
v.timestamp() | Timestamp validation |
v.timestampTz() | Timestamp with timezone |
v.unix() | Unix timestamp |
v.json() | JSON string validation |
v.password() | Password validation with security rules |
v.binary() | Binary data validation |
v.blob() | Blob validation |
v.custom() | Custom validation function |