Validation

Configure field validation using FieldBuilder methods, presets, ValidationRules, or raw Zod schemas.

View as Markdown

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 user@example.com
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 serializable ValidationRules object — .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 with customValidators alone is not validated by them.
  • The context argument currently provides fieldName only; context.allValues is 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 trigger on 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 with setDefaultMessages. Individual field error messages are fully customizable per rule (the *Message properties above); for translating labels and other UI strings, see Internationalization.
  • If you drive the form engine yourself with useFormState, advance steps with the returned validateAndNext — it runs this per-step validation (and submits on the last step). The raw nextStep function bypasses validation entirely.

Configuring the timing

By default a field is first validated when you try to advance/submit. You can change when validation fires with the serializable validation config (it maps 1:1 to React Hook Form’s mode/reValidateMode and is read once, at mount):

const config = FormBuilder.create('signup')
  .validation({ mode: 'onTouched' })
  // ...fields/steps
  .build();
{ "validation": { "mode": "onTouched" } }
mode First validation of each field
'onSubmit' (default) when advancing/submitting
'onTouched' on first blur, then live on every change — the recommended UX upgrade: errors raised by a failed Next/submit also clear live as the user fixes them
'onBlur' on every blur
'onChange' on every change
'all' blur + change

Notes:

  • Async validators (plugin validators, async schemas): typing-driven modes re-run the whole form schema on every keystroke. Prefer 'onTouched'/'onBlur', or use delayError (ms) to delay only the display of errors.
  • revalidateMode ('onChange' default, 'onBlur', 'onSubmit') governs re-validation after a submit marked by React Hook Form (isSubmitted). The standard <Form> submit button goes through per-step trigger(), which does not set isSubmitted — so this knob only matters if you drive submission yourself with methods.handleSubmit from useFormState. Inside <Form>, use mode to control the UX.
  • The timing is read at mount; changing config.validation on a mounted form has no effect.

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, and repeater fields skip user validation entirely (z.any()).
  • A field with required: true but no schema (only possible in raw config objects) gets a presence-only check via buildRequiredOnlySchema(fieldType). A field with neither is not validated at all.
  • FieldBuilder requires 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? }. errors is keyed by field name ({ email: ['Invalid email'] }); form-level issues key on _form.
  • buildFormSchema(form) → the combined z.object schema 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 resolve to z.any() server-side (the payload carries URLs, not File objects). Repeaters ARE validated (same generated schema as the client — see Repeater validation). Plugin-based custom validators are client-only and are not run here.

form is anything with a fields map — your FormConfig works directly.


Cross-field validation

Rules that relate two or more fields — enforced identically on the client and the server (@saastro/forms/validation).

Serializable rules (crossFieldRules)

Four rule types, JSON-safe (builder/Hub compatible). when conditions reuse the same 12-operator ConditionGroup as conditional visibility:

{
  "crossFieldRules": [
    { "id": "confirm", "type": "matches", "field": "confirmEmail", "mustMatch": "email" },
    { "id": "phoneReq", "type": "requiredIf", "field": "phone",
      "when": { "operator": "AND", "conditions": [{ "field": "contactBy", "operator": "equals", "value": "phone" }] } },
    { "id": "dates", "type": "compare", "field": "endDate", "operator": "greaterThan", "other": "startDate",
      "kind": "date", "error": "End date must be after start date" },
    { "id": "vip", "type": "errorIf",
      "when": { "operator": "AND", "conditions": [{ "field": "email", "operator": "contains", "value": "@competitor.com" }] },
      "then": { "error": "Please use a personal address" } }
  ]
}
Type Checks Error attaches to
matches value equals another field (confirm-email). Skips when empty — pair with required field
requiredIf field required when the when group matches field
compare numeric/date comparison between two fields (kind: 'number' | 'date'). Skips when an operand is missing field
errorIf generic escape hatch: group matches ⇒ error then.field, or the root slot when omitted

With FormBuilder: .crossFieldRule(rule) (append) / .crossFieldRules(rules) (replace) — .build() validates every referenced field exists. Default messages are i18n-backed (validationFieldsMustMatch, validationRequired); translate per-rule via i18n.translations.<locale>.crossFieldRules.<ruleId>.error (rules need an id for that).

Behavior

  • Steps: rules whose error targets a field in the current step block Next normally. Root errors (errorIf without then.field) surface when submitting: they render in a dedicated alert slot under the form and block the submit pipeline.
  • Live re-validation: fixing the source field (e.g. email in a matches rule) re-validates the target automatically when it currently shows an error — no stale “must match” leftovers.
  • Empty-safe: matches/compare skip when operands are empty, so partially-filled multi-step forms don’t error spuriously.
  • Server parity: validateFormData runs the same rules; rule errors key on the field name, root ones on _form.
  • Scalar fields only (matches/compare use strict/coerced comparison — not for matrix/daterange objects).

Code escape hatch (formSchema)

For logic the serializable rules can’t express, pass a whole-form Zod schema — TSX-only (not serializable; the builder’s JSON export drops it):

FormBuilder.create('signup')
  // ...fields/steps
  .formSchema(
    z.record(z.string(), z.unknown()).superRefine((values, ctx) => {
      if (values.plan === 'free' && Number(values.seats) > 3) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Free plan allows up to 3 seats', path: ['seats'] });
      }
    }),
  )
  .build();

Issues whose path[0] is a known field attach to that field; empty/unknown paths go to the root slot. Async refinements are not supported (the server validates synchronously) — for async checks use plugin validators.

Repeater validation

Behavioral change in 0.15: repeater minItems/maxItems and item-field rules are now enforced on submit — client and server (@saastro/forms/validation). Before 0.15 repeaters skipped validation entirely. Opt out per field with an explicit schema: z.any().

Each repeater compiles to a real array schema, generated from its config (one source for client and server — buildRepeaterSchema):

  • Per item: every itemFields entry validates with its own schema/rules (explicit Zod > ValidationRules > required-only). Items are parsed with passthrough — sub-fields without rules keep their values in the submitted data. Nested repeaters recurse.
  • Array level: minItems/maxItems produce .min()/.max() with i18n default messages (validationMinItems/validationMaxItems, customizable per field via minItemsMessage/maxItemsMessage or via .itemCount(min, max) rules).
  • Errors: item-level errors render inside each item (normal field paths like items.0.email); array-level errors (min/max) render in the repeater’s own error slot.
  • Defaults: with minItems > 0 and no explicit value, the form mounts pre-populated with minItems default items (so an empty list doesn’t error instantly).
  • Reordering: set reorderable: true (or .reorderable()) to show per-item move up/down buttons — they move the values (RHF move), not just the rows.
  • Escape hatch: an explicit Zod schema on the repeater replaces the generated schema entirely; schema: z.any() restores the pre-0.15 behavior.

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';