Common Patterns & Real-World Recipes
Production-ready validation patterns for forms, APIs, bulk operations, and more. All examples are copy-paste ready and fully typed.
Table of Contents
- Form Validation
- API Request Validation
- Bulk Data Validation
- Multi-Step Wizards
- Conditional Validation
- Cross-Field Validation
- Async Validation
- Configuration Validation
Form Validation
User Registration Form
Complete registration form with all common validations.
import { schema, validate, required, email, minLength, maxLength, matches, min, max, integer, oneOf, equals, valid, invalid, type Validation, type FieldError } from '@ontologyio/receta/validation'
type RegistrationForm = {
username: string
email: string
password: string
confirmPassword: string
age: number
country: string
terms: boolean
}
const validateRegistration = (data: RegistrationForm): Validation<RegistrationForm, FieldError<string>> => {
const registrationSchema = {
username: (u: string) =>
validate(u, [
required('Username is required'),
minLength(3, 'Username must be at least 3 characters'),
maxLength(20, 'Username must be at most 20 characters'),
matches(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
]),
email: (e: string) =>
validate(e, [
required('Email is required'),
email('Invalid email format')
]),
password: (p: string) =>
validate(p, [
required('Password is required'),
minLength(8, 'Password must be at least 8 characters'),
matches(/[A-Z]/, 'Password must contain at least one uppercase letter'),
matches(/[a-z]/, 'Password must contain at least one lowercase letter'),
matches(/[0-9]/, 'Password must contain at least one number'),
matches(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
]),
confirmPassword: (c: string) =>
c === data.password
? valid(c)
: invalid('Passwords must match'),
age: (a: number) =>
validate(a, [
min(18, 'You must be at least 18 years old'),
max(120, 'Please enter a valid age'),
integer('Age must be a whole number')
]),
country: oneOf(
['US', 'UK', 'CA', 'AU', 'DE', 'FR', 'JP', 'ES', 'IT'],
'Please select a valid country'
),
terms: equals(true, 'You must accept the terms and conditions')
}
return schema(registrationSchema, data)
}Use it from a React form. match needs both handlers to return the same type, so when onValid is async, onInvalid is too.
import { useState, type FormEvent } from 'react'
const RegistrationPage = () => {
const [formData, setFormData] = useState<RegistrationForm>(emptyForm)
const [errors, setErrors] = useState<readonly FieldError<string>[]>([])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const validation = validateRegistration(formData)
match(validation, {
onValid: async (data) => {
// Submit to API
await createAccount(data)
navigate('/welcome')
},
onInvalid: async (fieldErrors) => {
// Show all errors at once
setErrors(fieldErrors)
fieldErrors.forEach(({ field, errors }) => {
showFieldError(field, errors)
})
}
})
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{errors.map(({ field, errors: fieldErrors }) => (
<ErrorMessage key={field} field={field} errors={fieldErrors} />
))}
</form>
)
}Contact Form
Simple contact form with email and message validation.
type ContactForm = {
name: string
email: string
subject: string
message: string
}
const validateContactForm = (data: ContactForm) => {
return schema({
name: (n: string) =>
validate(n, [
required('Name is required'),
minLength(2, 'Name must be at least 2 characters')
]),
email: (e: string) =>
validate(e, [
required('Email is required'),
email('Please enter a valid email address')
]),
subject: (s: string) =>
validate(s, [
required('Subject is required'),
minLength(5, 'Subject must be at least 5 characters'),
maxLength(100, 'Subject must be at most 100 characters')
]),
message: (m: string) =>
validate(m, [
required('Message is required'),
minLength(10, 'Message must be at least 10 characters'),
maxLength(1000, 'Message must be at most 1000 characters')
])
}, data)
}Payment Form
Payment form with conditional validation based on payment method.
type PaymentForm = {
paymentMethod: 'card' | 'bank'
cardNumber?: string
cardExpiry?: string
cardCvv?: string
accountNumber?: string
routingNumber?: string
amount: number
}
const validatePayment = (data: PaymentForm): Validation<PaymentForm, FieldError<string>> => {
if (data.paymentMethod === 'card') {
return schema({
paymentMethod: valid,
cardNumber: (n: string | undefined) =>
n
? validate(n, [
required('Card number is required'),
matches(/^\d{16}$/, 'Card number must be 16 digits'),
fromPredicate(luhnCheck, 'Invalid card number')
])
: invalid('Card number is required'),
cardExpiry: (e: string | undefined) =>
e
? validate(e, [
required('Expiry date is required'),
matches(/^\d{2}\/\d{2}$/, 'Format: MM/YY'),
fromPredicate(isValidExpiry, 'Card has expired')
])
: invalid('Expiry date is required'),
cardCvv: (c: string | undefined) =>
c
? validate(c, [
required('CVV is required'),
matches(/^\d{3,4}$/, 'CVV must be 3 or 4 digits')
])
: invalid('CVV is required'),
amount: (a: number) =>
validate(a, [
min(0.01, 'Amount must be positive'),
finite('Amount must be a valid number')
])
}, data)
} else {
return schema({
paymentMethod: valid,
accountNumber: (n: string | undefined) =>
n
? validate(n, [
required('Account number is required'),
matches(/^\d{8,17}$/, 'Account number must be 8-17 digits')
])
: invalid('Account number is required'),
routingNumber: (r: string | undefined) =>
r
? validate(r, [
required('Routing number is required'),
matches(/^\d{9}$/, 'Routing number must be 9 digits')
])
: invalid('Routing number is required'),
amount: (a: number) =>
validate(a, [
min(0.01, 'Amount must be positive'),
finite('Amount must be a valid number')
])
}, data)
}
}API Request Validation
Create User Endpoint
A request body can hold anything, and required and the other string validators throw on a missing field. So each field here checks its type before validating.
type CreateUserRequest = {
email: string
name: string
role: 'admin' | 'user' | 'moderator'
age: number
}
const validateCreateUser = (body: unknown): Validation<CreateUserRequest, FieldError<string>> => {
// Type guard
if (typeof body !== 'object' || body === null) {
return invalid({ field: 'body', errors: ['Request body must be an object'] })
}
const createUserSchema = {
email: (e: unknown) =>
typeof e === 'string'
? validate(e, [
required('Email is required'),
email('Invalid email format'),
maxLength(254, 'Email too long')
])
: invalid('Email must be a string'),
name: (n: unknown) =>
typeof n === 'string'
? validate(n, [
required('Name is required'),
minLength(2, 'Name must be at least 2 characters'),
maxLength(50, 'Name must be at most 50 characters')
])
: invalid('Name must be a string'),
role: (r: unknown) =>
oneOf(['admin', 'user', 'moderator'] as const, 'Invalid role')(r as CreateUserRequest['role']),
age: (a: unknown) =>
typeof a === 'number'
? validate(a, [
min(18, 'Must be at least 18 years old'),
max(120, 'Invalid age'),
integer('Age must be a whole number')
])
: invalid('Age must be a number')
}
return schema(createUserSchema, body as CreateUserRequest)
}
// Express endpoint
app.post('/api/users', (req, res) => {
const validation = validateCreateUser(req.body)
match(validation, {
onValid: async (user) => {
const created = await db.users.create(user)
res.status(201).json(created)
},
onInvalid: async (errors) => {
res.status(400).json({
error: 'Validation failed',
fields: errors
})
}
})
})Update User Endpoint (PATCH)
const validateUpdateUser = (body: unknown): Validation<Partial<User>, FieldError<string>> => {
if (typeof body !== 'object' || body === null) {
return invalid({ field: 'body', errors: ['Request body must be an object'] })
}
const userSchema = {
email: (e: unknown) =>
typeof e === 'string'
? validate(e, [email('Invalid email'), maxLength(254)])
: invalid('Email must be a string'),
name: (n: unknown) =>
typeof n === 'string'
? validate(n, [minLength(2), maxLength(50)])
: invalid('Name must be a string'),
age: (a: unknown) =>
typeof a === 'number'
? validate(a, [min(18), max(120), integer()])
: invalid('Age must be a number')
}
// Only validate present fields
return partial(userSchema, body as Partial<User>)
}
app.patch('/api/users/:id', (req, res) => {
const validation = validateUpdateUser(req.body)
if (isInvalid(validation)) {
return res.status(400).json({ errors: validation.errors })
}
const updated = updateUser(req.params.id, validation.value)
res.json(updated)
})Batch Create Endpoint
app.post('/api/users/batch', (req, res) => {
if (!Array.isArray(req.body.users)) {
return res.status(400).json({ error: 'users must be an array' })
}
const validation = validateAll(req.body.users, validateCreateUser)
match(validation, {
onValid: async (users) => {
const created = await db.users.createMany(users)
res.status(201).json({
message: `Created ${created.length} users`,
users: created
})
},
onInvalid: async (errors) => {
res.status(400).json({
message: `${errors.length} validation errors`,
errors
})
}
})
})Bulk Data Validation
CSV Import
Rows arrive untyped, so a small text helper checks each cell is a string before running its rules. mapInvalid then turns each field error into a message that names the row.
type ProductRow = {
name: string
sku: string
price: number
category: string
}
const text = (label: string, rules: readonly Validator<string, string, string>[]) =>
(value: unknown) =>
typeof value === 'string'
? validate(value, rules)
: invalid(`${label} must be a string`)
const productSchema = {
name: text('Name', [required('Name required'), minLength(3, 'Name too short')]),
sku: text('SKU', [required('SKU required'), matches(/^[A-Z0-9-]+$/, 'Invalid SKU format')]),
price: (p: unknown) =>
typeof p === 'number'
? validate(p, [min(0, 'Price cannot be negative'), finite('Price must be valid')])
: invalid('Price must be a number'),
category: text('Category', [oneOf(['electronics', 'books', 'clothing', 'home'], 'Invalid category')])
}
const validateProduct = (row: unknown, rowNumber: number): Validation<ProductRow, string> =>
typeof row === 'object' && row !== null
? pipe(
schema(productSchema, row as ProductRow),
mapInvalid(({ field, errors }) => `Row ${rowNumber}, ${field}: ${errors.join(', ')}`)
)
: invalid(`Row ${rowNumber}: Invalid data format`)
const importCSV = (csvData: unknown[]) => {
const validations = csvData.map((row, idx) => validateProduct(row, idx + 1))
const result = collectErrors(validations)
return match(result, {
onValid: async (products) => {
await db.products.createMany(products)
return {
success: true,
imported: products.length,
errors: [],
message: `Successfully imported ${products.length} products`
}
},
onInvalid: async (errors) => {
return {
success: false,
imported: 0,
errors: errors,
message: `Failed to import. ${errors.length} validation errors found.`
}
}
})
}Batch Validation with Partial Success
interface ValidationResult<T, E> {
valid: T[]
invalid: Array<{ index: number; errors: readonly E[] }>
}
const validateWithPartialSuccess = <T, E>(
items: unknown[],
validator: (item: unknown, rowNumber: number) => Validation<T, E>
): ValidationResult<T, E> => {
const valid: T[] = []
const invalid: Array<{ index: number; errors: readonly E[] }> = []
items.forEach((item, index) => {
const validation = validator(item, index + 1)
if (isValid(validation)) {
valid.push(validation.value)
} else {
invalid.push({ index, errors: validation.errors })
}
})
return { valid, invalid }
}
// Usage
const result = validateWithPartialSuccess(csvRows, validateProduct)
if (result.valid.length > 0) {
await db.products.createMany(result.valid)
}
if (result.invalid.length > 0) {
console.log(`Skipped ${result.invalid.length} invalid rows:`)
result.invalid.forEach(({ errors }) => {
console.log(` ${errors.join(', ')}`)
})
}Multi-Step Wizards
5-Step Onboarding Wizard
The steps hold different shapes, which collectErrors can't mix. Making each step a schema field works, and each error's field then names a step that failed.
type WizardData = {
step1: PersonalInfo
step2: AccountSettings
step3: Preferences
step4: PaymentInfo
step5: Confirmation
}
const validateWizardStep1 = (data: PersonalInfo) =>
schema({
firstName: (f: string) =>
validate(f, [required('First name required'), minLength(2)]),
lastName: (l: string) =>
validate(l, [required('Last name required'), minLength(2)]),
email: (e: string) =>
validate(e, [required('Email required'), email()])
}, data)
const validateWizardStep2 = (data: AccountSettings) =>
schema({
username: (u: string) =>
validate(u, [
required('Username required'),
minLength(3),
maxLength(20),
alphanumeric()
]),
password: (p: string) =>
validate(p, [
required('Password required'),
minLength(8),
matches(/[A-Z]/, 'Need uppercase'),
matches(/[0-9]/, 'Need number')
])
}, data)
const validateCompleteWizard = (data: WizardData) =>
schema({
step1: validateWizardStep1,
step2: validateWizardStep2,
step3: validateWizardStep3,
step4: validateWizardStep4,
step5: validateWizardStep5
}, data)Then, in the component:
import { useState } from 'react'
const WizardComponent = () => {
const [currentStep, setCurrentStep] = useState(1)
const [wizardData, setWizardData] = useState<WizardData>(emptyWizard)
const handleFinalSubmit = () => {
const validation = validateCompleteWizard(wizardData)
match(validation, {
onValid: async (data) => {
await submitWizard(data)
navigate('/success')
},
onInvalid: async (errors) => {
// Show which steps have errors
const errorSteps = errors.map(({ field }) => field)
alert(`Please fix errors in: ${errorSteps.join(', ')}`)
}
})
}
return <div>...</div>
}Conditional Validation
Dynamic Field Validation
type OrderForm = {
orderType: 'pickup' | 'delivery'
deliveryAddress?: Address
pickupLocation?: string
items: OrderItem[]
}
const validateOrder = (data: OrderForm) => {
const baseValidation = schema<OrderForm, string>({
orderType: oneOf(['pickup', 'delivery'] as const),
items: (items: OrderItem[]) =>
validate(items, [
nonEmpty('Order must have at least one item'),
maxItems(50, 'Maximum 50 items per order')
])
}, data)
if (isInvalid(baseValidation)) {
return baseValidation
}
// Conditional validation based on orderType
if (data.orderType === 'delivery') {
return data.deliveryAddress
? pipe(
validateAddress(data.deliveryAddress),
map((addr) => ({ ...data, deliveryAddress: addr }))
)
: invalid({ field: 'deliveryAddress', errors: ['Delivery address required'] })
} else {
return data.pickupLocation
? pipe(
oneOf(['store1', 'store2', 'store3'], 'Invalid pickup location')(data.pickupLocation),
map((pickupLocation) => ({ ...data, pickupLocation })),
mapInvalid((error) => ({ field: 'pickupLocation', errors: [error] }))
)
: invalid({ field: 'pickupLocation', errors: ['Pickup location required'] })
}
}Cross-Field Validation
Password Confirmation
The error types have to line up for flatMap, so these examples name them with schema<PasswordChangeForm, string> and invalid<FieldError<string>>.
type PasswordChangeForm = {
currentPassword: string
newPassword: string
confirmPassword: string
}
const validatePasswordChange = (data: PasswordChangeForm) => {
return pipe(
schema<PasswordChangeForm, string>({
currentPassword: (p: string) =>
required('Current password required')(p),
newPassword: (p: string) =>
validate(p, [
required('New password required'),
minLength(8),
matches(/[A-Z]/, 'Need uppercase'),
matches(/[0-9]/, 'Need number')
]),
confirmPassword: (c: string) =>
required('Please confirm your password')(c)
}, data),
flatMap((validated) =>
validated.newPassword === validated.confirmPassword
? valid(validated)
: invalid<FieldError<string>>({
field: 'confirmPassword',
errors: ['Passwords do not match']
})
),
flatMap((validated) =>
validated.currentPassword !== validated.newPassword
? valid(validated)
: invalid<FieldError<string>>({
field: 'newPassword',
errors: ['New password must be different from current password']
})
)
)
}Date Range Validation
type EventForm = {
title: string
startDate: Date
endDate: Date
location: string
}
const validateEvent = (data: EventForm) => {
return pipe(
schema<EventForm, string>({
title: (t: string) =>
validate(t, [required('Title required'), maxLength(100)]),
startDate: (d: Date) =>
fromPredicate(
(date: Date) => date > new Date(),
'Start date must be in the future'
)(d),
endDate: (d: Date) => valid(d),
location: (l: string) =>
validate(l, [required('Location required')])
}, data),
flatMap((validated) =>
validated.endDate > validated.startDate
? valid(validated)
: invalid<FieldError<string>>({
field: 'endDate',
errors: ['End date must be after start date']
})
)
)
}Async Validation
Check Username Availability
const checkUsernameAvailable = async (username: string): Promise<boolean> => {
const response = await fetch(`/api/check-username/${username}`)
const { available } = await response.json()
return available
}
const validateUsername = async (username: string): Promise<Validation<string, string>> => {
// Sync validations first
const syncValidation = validate(username, [
required('Username required'),
minLength(3),
maxLength(20),
alphanumeric()
])
if (isInvalid(syncValidation)) {
return syncValidation
}
// Async validation
const available = await checkUsernameAvailable(username)
return available
? valid(username)
: invalid('Username is already taken')
}
// Usage
const handleUsernameBlur = async (username: string) => {
const validation = await validateUsername(username)
if (isInvalid(validation)) {
showError(validation.errors[0])
}
}Configuration Validation
Environment Variables
schema hands back the object it was given, so it can't turn PORT into a number or fill in a default. Do that first, then validate the result.
type AppConfig = {
DATABASE_URL: string
API_KEY: string
PORT: number
NODE_ENV: 'development' | 'production' | 'test'
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'
}
const readConfig = (env: Record<string, string | undefined>): AppConfig => ({
DATABASE_URL: env.DATABASE_URL ?? '',
API_KEY: env.API_KEY ?? '',
PORT: Number(env.PORT),
NODE_ENV: env.NODE_ENV as AppConfig['NODE_ENV'],
LOG_LEVEL: (env.LOG_LEVEL ?? 'info') as AppConfig['LOG_LEVEL'] // Default value
})
const validateConfig = (config: AppConfig): Validation<AppConfig, FieldError<string>> =>
schema({
DATABASE_URL: validate([
required('DATABASE_URL is required'),
url('DATABASE_URL must be a valid URL')
]),
API_KEY: validate([
required('API_KEY is required'),
minLength(32, 'API_KEY must be at least 32 characters')
]),
PORT: validate([
integer('PORT must be an integer'),
between(1000, 65535, 'PORT must be between 1000 and 65535')
]),
NODE_ENV: oneOf(['development', 'production', 'test'] as const, 'Invalid NODE_ENV'),
LOG_LEVEL: oneOf(['debug', 'info', 'warn', 'error'] as const, 'Invalid LOG_LEVEL')
}, config)
// Usage at app startup
const configValidation = validateConfig(readConfig(process.env))
if (isInvalid(configValidation)) {
console.error('Configuration validation failed:')
configValidation.errors.forEach(({ field, errors }) => {
console.error(` ${field}: ${errors.join(', ')}`)
})
process.exit(1)
}
const config = configValidation.value
// Now use config safelyNext Steps
- Migration Guide - Move from Zod, Yup, or try-catch
- API Reference - Complete function reference
- Examples - More runnable examples