Validation
Validation in @saastro/forms is configured per field — and if you use FormBuilder, it’s mandatory: .build() throws for any non-hidden field without a schema. There are four ways to set it up, from simplest to most flexible:
| Approach | Use When | Example |
|---|---|---|
| FieldBuilder methods | Most forms — clean, chainable, type-safe | .required().email().minLength(5) |
| Presets | Common patterns — one word, no config | .preset('password-strong') |
| ValidationRules object | JSON configs, database-stored forms, visual builders | { required: true, format: 'email' } |
| Raw Zod schema | Complex custom logic — refinements, transforms, pipes | .schema(z.string().email()) |
Most developers only need the first two. The rest are there when you need them.
FieldBuilder Methods
The recommended approach. Chain validation methods on any field:
import { FormBuilder } from '@saastro/forms';
const config = FormBuilder.create('signup')
.addField('name', (f) =>
f.type('text').label('Name').required().minLength(2, 'Name is too short'),
)
.addField('email', (f) =>
f.type('email').label('Email').required().email('Please enter a valid email'),
)
.addField('password', (f) =>
f
.type('text')
.label('Password')
.required()
.minLength(8)
.regex('^(?=.*[A-Z])(?=.*\\d)', 'Must have 1 uppercase and 1 number'),
)
.addField('terms', (f) =>
f.type('checkbox').label('I accept the terms').mustBeTrue('You must accept the terms'),
)
.addStep('main', ['name', 'email', 'password', 'terms'])
.build();
All Methods
| Method | What It Does | Field Types |
|---|---|---|
.required(msg?) | Field must have a value | All |
.optional() | Field can be empty | All |
.email(msg?) | Must be a valid email | String fields |
.url(msg?) | Must be a valid URL | String fields |
.minLength(n, msg?) | Minimum character count | String fields |
.maxLengthValidation(n, msg?) | Maximum character count | String fields |
.regex(pattern, msg?) | Must match regex pattern | String fields |
.numberRange(min?, max?) | Number must be within range | Number/slider |
.itemCount(min?, max?) | Min/max selected items | Checkbox-group, switch-group |
.mustBeTrue(msg?) | Must be checked | Checkbox, switch |
.preset(id) | Apply a named preset (see below) | All |
Methods with a msg? parameter accept an optional custom error message. Without one, a sensible English default is used. .numberRange() and .itemCount() always use the default messages.
Presets
For common validation patterns, use a single preset instead of multiple rules:
.addField('phone', (f) =>
f.type('tel').label('Phone').preset('phone-us')
)
.addField('password', (f) =>
f.type('text').label('Password').preset('password-strong')
)
Built-in Presets (19)
Identity & Auth:
| Preset | What It Validates | Example Format |
|---|---|---|
email | Email address | [email protected] |
username | 3-20 chars, alphanumeric + underscore | john_doe |
password-simple | 8+ characters | mypassword |
password-medium | 8+ chars, uppercase, lowercase, number | MyPass123 |
password-strong | 8+ chars, uppercase, lowercase, number, special | MyP@ss123 |
ssn | US Social Security Number | 123-45-6789 |
Contact:
| Preset | What It Validates | Example Format |
|---|---|---|
phone-us | US phone number | 555-123-4567 |
phone-international | International phone | +1-555-123-4567 |
postal-code-us | US ZIP code | 12345 or 12345-6789 |
postal-code-ca | Canadian postal code | A1B 2C3 |
credit-card | Credit card number | 1234-5678-9012-3456 |
Technical:
| Preset | What It Validates | Example Format |
|---|---|---|
url | Web URL | https://example.com |
slug | URL-safe slug | my-blog-post |
hex-color | Hex color code | #FF5733 |
ipv4 | IPv4 address | 192.168.1.1 |
date-iso | ISO date string | 2026-02-19 |
time-24h | 24-hour time | 14:30 |
alpha-only | Letters only | HelloWorld |
alphanumeric | Letters and numbers only | abc123 |
Custom Presets
Register your own:
import { registerPreset } from '@saastro/forms';
registerPreset({
id: 'company-email',
label: 'Company Email',
description: 'Must be a @company.com email',
category: 'string',
rules: {
format: 'email',
pattern: '@company\\.com$',
patternMessage: 'Must be a @company.com address',
},
});
// Now use it like any built-in preset
.addField('email', (f) => f.type('email').label('Work Email').preset('company-email'))
Advanced: ValidationRules Object
Under the hood, FieldBuilder methods produce a ValidationRules object — a flat, JSON-serializable structure. You can write this object directly as a field’s schema property when your form configs come from a database, an API, or a visual editor:
// These two are equivalent:
// 1. FieldBuilder methods
const viaBuilder = FormBuilder.create('signup')
.addField('email', (f) => f.type('email').label('Email').required().email())
.addStep('main', ['email'])
.build();
// 2. Raw config object with a ValidationRules schema
const viaRawConfig = {
formId: 'signup',
fields: {
email: {
type: 'email',
label: 'Email',
schema: { required: true, format: 'email' },
},
},
steps: { main: { fields: ['email'] } },
};
Note: the FieldBuilder
.schema()method accepts either a Zod schema (see the escape hatch below) or a serializableValidationRulesobject —.schema({ required: true, format: 'email' })works and stays JSON-serializable. Chaining declarative methods (.required().email()) is equivalent.
This is what makes form configs storable in databases and editable by visual form builders or CMSs — the entire validation config is plain JSON.
All Properties
General
| Property | Type | Description |
|---|---|---|
required | boolean | Field must have a value |
requiredMessage | string | Custom required error message |
preset | string | Apply a named validation preset |
String rules — for text, email, tel, textarea, select, radio, combobox, etc.
| Property | Type | Description |
|---|---|---|
minLength / minLengthMessage | number / string | Minimum character count |
maxLength / maxLengthMessage | number / string | Maximum character count |
pattern / patternMessage | string / string | Regex pattern (as string) |
format / formatMessage | 'email' | 'url' | 'uuid' | 'cuid' | 'emoji' / string | Built-in format check |
Number rules — for number, slider, range
| Property | Type | Description |
|---|---|---|
min / minMessage | number / string | Minimum value |
max / maxMessage | number / string | Maximum value |
integer / integerMessage | boolean / string | Must be a whole number |
positive / positiveMessage | boolean / string | Must be positive |
Array rules — for checkbox-group, switch-group, button-checkbox, button-card
| Property | Type | Description |
|---|---|---|
minItems / minItemsMessage | number / string | Minimum selected items |
maxItems / maxItemsMessage | number / string | Maximum selected items |
Boolean rules — for checkbox, switch
| Property | Type | Description |
|---|---|---|
mustBeTrue / mustBeTrueMessage | boolean / string | Must be checked/true |
Date rules — for date, daterange
| Property | Type | Description |
|---|---|---|
minDate / minDateMessage | string / string | Earliest allowed date (ISO string) |
maxDate / maxDateMessage | string / string | Latest allowed date (ISO string) |
mustBeFuture / mustBeFutureMessage | boolean / string | Date must be in the future |
mustBePast / mustBePastMessage | boolean / string | Date must be in the past |
Escape Hatch: Raw Zod Schemas
When you need validation logic that can’t be expressed as simple rules — custom refinements, coercion, transforms, or Zod pipes — pass a Zod schema directly:
import { z } from 'zod';
.addField('email', (f) =>
f.type('email')
.label('Email')
.schema(z.string().email().endsWith('@company.com', 'Must be a company email'))
)
.addField('age', (f) =>
f.type('text')
.label('Age')
.schema(z.coerce.number().min(18, 'Must be 18 or older').max(120))
)
Each field’s schema validates only that field’s value — there is no cross-field validation mechanism in the package. (For showing or hiding fields based on other fields, see Conditional Logic.)
Any Standard Schema (Valibot, ArkType, Zod 4)
A field schema accepts any Standard Schema — not just Zod. Valibot, ArkType, Zod 4, and anything implementing the spec all work; the form runs them via their ~standard.validate (sync or async) and surfaces their issues on the field.
import * as v from 'valibot';
.addField('email', (f) =>
f.type('email').label('Email').schema(v.pipe(v.string(), v.email('Invalid email'))),
)
Standard Schemas are code-only (not serializable), so they apply on the client; the isomorphic validateFormData skips them server-side (Hub/Worker configs carry ValidationRules, not schema objects).
Warning: Don’t mix
.schema(zodSchema)with the declarative methods on the same field. An explicit Zod schema is the escape hatch and wins: declarative methods (.required(),.email(), etc.) chained after.schema(zod)are ignored with a dev-console warning..schema()itself still overwrites any rules built before it. Put all validation inside the Zod schema, or use only declarative methods.
Custom Validators (via Plugins)
For async or server-side validation (like checking if an email is already taken), use plugin validators:
import { definePlugin, PluginManager, FormBuilder } from '@saastro/forms';
const myPlugin = definePlugin({
name: 'my-validators',
version: '1.0.0',
validators: {
uniqueEmail: async (value, context) => {
const res = await fetch(`/api/check-email?email=${value}`);
const { exists } = await res.json();
return exists ? 'Email already registered' : true;
},
},
});
const pm = new PluginManager();
pm.register(myPlugin);
const config = FormBuilder.create('signup')
.usePlugins(pm)
.addField(
'email',
(f) => f.type('email').label('Email').required().email().customValidators('uniqueEmail'), // Runs after the built-in checks pass
)
.addStep('main', ['email'])
.build();
Validators return true for valid, or an error message string for invalid (returning false also fails, with a generic message). Two constraints to be aware of:
- Custom validators only run on fields that also define a
schema(declarative rules or Zod) — a field withcustomValidatorsalone is not validated by them. - The
contextargument currently providesfieldNameonly;context.allValuesis an empty object, so don’t rely on it for cross-field checks.
See Plugins for full details.
When Validation Runs
Validation is wired through React Hook Form with a Zod resolver, and it runs per step, not on the whole form at once:
- Advancing a step — clicking Next validates only the fields of the current step (internally, a hook calls React Hook Form’s
triggeron that step’s field list). If any field fails, the form shows an error toast, focuses the first invalid field, and stays on the step. Steps with no fields always pass. - Submitting — on the last step, the same per-step validation runs first; only when it passes does the submit pipeline execute.
Two details worth knowing:
- The step-failure toast message defaults to
'Please fix the errors before continuing', switchable globally withsetDefaultMessages. Individual field error messages are fully customizable per rule (the*Messageproperties above); for translating labels and other UI strings, see Internationalization. - If you drive the form engine yourself with
useFormState, advance steps with the returnedvalidateAndNext— it runs this per-step validation (and submits on the last step). The rawnextStepfunction bypasses validation entirely.
How It Works Internally
When a field uses ValidationRules (not a raw Zod schema), the compiler picks a base Zod type from the field type, then layers on your rules:
| Field Types | Base Zod Type |
|---|---|
| text, email, tel, textarea, select, radio, combobox, etc. | z.string() |
| number, slider, range | z.number() |
| checkbox-group, switch-group, button-checkbox, button-card | z.array(z.string()) |
| checkbox, switch | z.boolean() |
| date, daterange | z.date() (with preprocessing) |
Any field type not in the table falls back to the string builder.
The compilation order is: resolve preset → merge explicit rules (explicit rules override preset defaults) → apply type-specific constraints → handle required: false with .optional().
A few special cases:
hidden,file, andrepeaterfields skip user validation entirely (z.any()).- A field with
required: truebut noschema(only possible in raw config objects) gets a presence-only check viabuildRequiredOnlySchema(fieldType). A field with neither is not validated at all. FieldBuilderrequires every non-hidden field to declare a schema —.build()throws otherwise. An empty rules object ({}) counts; the compiler derives a sensible default from the field type.
Server-side (isomorphic) validation
The same ValidationRules → Zod compiler runs anywhere — React-free — so you can validate a submission on the server with the exact rules the form ships. One source of validation truth for the client and your backend (e.g. the submit Worker).
import { validateFormData } from '@saastro/forms';
// In a Cloudflare Worker / API route
const result = validateFormData(formConfig, await request.json());
if (!result.success) {
return Response.json({ errors: result.errors }, { status: 422 });
}
// result.data → the parsed/coerced known fields
validateFormData(form, data)→{ success, data?, errors? }.errorsis keyed by field name ({ email: ['Invalid email'] }); form-level issues key on_form.buildFormSchema(form)→ the combinedz.objectschema if you want to validate/extend it yourself.- Unknown keys (hidden fields, server-injected geo/IP, …) are stripped, not rejected — a submission isn’t failed for carrying extra data.
- Files and repeaters resolve to
z.any()server-side (the payload carries URLs/arrays, notFileobjects). Plugin-based custom validators are client-only and are not run here.
form is anything with a fields map — your FormConfig works directly.
API Reference
import type {
ValidationRules,
SchemaType,
ValidationPresetMeta,
ValidatableForm,
FormValidationResult,
StandardSchemaV1,
} from '@saastro/forms';
import {
compileValidationRules, // (rules, fieldType) => Zod schema
isZodSchema, // type guard for Zod schemas
isValidationRules, // type guard for ValidationRules objects
isStandardSchema, // type guard for Standard Schema (Valibot/ArkType/…)
standardSchemaToZod, // adapt a Standard Schema into a Zod schema
resolvePreset, // (id) => ValidationRules | undefined
registerPreset, // register a custom preset
getAvailablePresets, // list all presets (built-in + custom)
buildRequiredOnlySchema, // (fieldType) => presence-only Zod schema
validateFormData, // (form, data) => { success, data?, errors? } — server-side
buildFormSchema, // (form) => combined z.object schema
resolveFieldSchema, // (fieldConfig) => Zod schema | null
} from '@saastro/forms';