Cross-field validation enforces rules that depend on more than one field: “confirm password must match password”, “end date must be after start date”, “phone is required when contact method is ‘call’.” These rules can’t be expressed per-field — they need visibility into the whole form state.
FormEngine actions have access to the entire form data via e.data. A cross-field validator reads multiple keys, compares them, and returns an error string if the rule fails.
Password confirmation
The most common cross-field case — two password fields that must match:
const customValidators = {
passwordsMatch: ActionDefinition.functionalAction(async (e) => {
const password = e.data['password']
const confirm = e.data['confirmPassword']
if (password && confirm && password !== confirm) {
return 'Passwords do not match'
}
}),
}
Attach to the confirmPassword field:
{
"key": "confirmPassword",
"type": "RsInput",
"props": { "label": { "value": "Confirm password" }, "type": { "value": "password" } },
"schema": {
"validations": [
{ "key": "required" },
{ "key": "passwordsMatch" }
]
}
}
The rule runs on the confirm field but reads the password field — when either changes, validation re-evaluates.
Date range validation
Ensure end date is after start date:
const customValidators = {
dateRange: ActionDefinition.functionalAction(async (e) => {
const start = e.data['startDate']
const end = e.data['endDate']
if (start && end && new Date(end) <= new Date(start)) {
return 'End date must be after start date'
}
}),
}
Conditional required fields
“Phone number is required when preferred contact method is ‘call’”:
const customValidators = {
phoneRequired: ActionDefinition.functionalAction(async (e) => {
const contactMethod = e.data['contactMethod']
const phone = e.data['phone']
if (contactMethod === 'call' && !phone) {
return 'Phone number is required when contact method is "Call"'
}
}),
}
This is different from conditional rendering (hiding/showing fields). Here the field is always visible but conditionally mandatory.
Numeric range checks
“Minimum order quantity must be less than maximum”:
const customValidators = {
minLessThanMax: ActionDefinition.functionalAction(async (e) => {
const min = Number(e.data['minQty'])
const max = Number(e.data['maxQty'])
if (min && max && min >= max) {
return 'Minimum must be less than maximum'
}
}),
}
Mutual exclusivity
“Select either option A or option B, but not both”:
const customValidators = {
mutuallyExclusive: ActionDefinition.functionalAction(async (e) => {
if (e.data['optionA'] && e.data['optionB']) {
return 'Select only one option'
}
}),
}
Sum / total checks
“Budget line items must sum to exactly the total budget”:
const customValidators = {
budgetBalance: ActionDefinition.functionalAction(async (e) => {
const items = e.data['lineItems'] as Array<{ amount: number }>
const total = e.data['totalBudget'] as number
if (!items || !total) return
const sum = items.reduce((acc, item) => acc + (item.amount || 0), 0)
if (Math.abs(sum - total) > 0.01) {
return `Line items sum to ${sum.toFixed(2)}, but total budget is ${total.toFixed(2)}`
}
}),
}
Where to attach cross-field validators
Attach the rule to the field that should display the error. In “passwords must match”, attach to confirmPassword. In “end date after start date”, attach to endDate. The rule reads other fields, but the error message appears next to the target field.
Combining with Zod
For complex cross-field rules, use Zod’s .refine() or .superRefine() at the form level:
import { z } from 'zod'
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
startDate: z.string(),
endDate: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
).refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'End date must be after start date', path: ['endDate'] }
)
See the Zod validation tutorial for wiring Zod schemas into FormEngine.
Last modified on April 16, 2026