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:

ApproachUse WhenExample
FieldBuilder methodsMost forms — clean, chainable, type-safe.required().email().minLength(5)
PresetsCommon patterns — one word, no config.preset('password-strong')
ValidationRules objectJSON configs, database-stored forms, visual builders{ required: true, format: 'email' }
Raw Zod schemaComplex 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

MethodWhat It DoesField Types
.required(msg?)Field must have a valueAll
.optional()Field can be emptyAll
.email(msg?)Must be a valid emailString fields
.url(msg?)Must be a valid URLString fields
.minLength(n, msg?)Minimum character countString fields
.maxLengthValidation(n, msg?)Maximum character countString fields
.regex(pattern, msg?)Must match regex patternString fields
.numberRange(min?, max?)Number must be within rangeNumber/slider
.itemCount(min?, max?)Min/max selected itemsCheckbox-group, switch-group
.mustBeTrue(msg?)Must be checkedCheckbox, 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:

PresetWhat It ValidatesExample Format
emailEmail addressuser@example.com
username3-20 chars, alphanumeric + underscorejohn_doe
password-simple8+ charactersmypassword
password-medium8+ chars, uppercase, lowercase, numberMyPass123
password-strong8+ chars, uppercase, lowercase, number, specialMyP@ss123
ssnUS Social Security Number123-45-6789

Contact:

PresetWhat It ValidatesExample Format
phone-usUS phone number555-123-4567
phone-internationalInternational phone+1-555-123-4567
postal-code-usUS ZIP code12345 or 12345-6789
postal-code-caCanadian postal codeA1B 2C3
credit-cardCredit card number1234-5678-9012-3456

Technical:

PresetWhat It ValidatesExample Format
urlWeb URLhttps://example.com
slugURL-safe slugmy-blog-post
hex-colorHex color code#FF5733
ipv4IPv4 address192.168.1.1
date-isoISO date string2026-02-19
time-24h24-hour time14:30
alpha-onlyLetters onlyHelloWorld
alphanumericLetters and numbers onlyabc123

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
PropertyTypeDescription
requiredbooleanField must have a value
requiredMessagestringCustom required error message
presetstringApply a named validation preset
String rules — for text, email, tel, textarea, select, radio, combobox, etc.
PropertyTypeDescription
minLength / minLengthMessagenumber / stringMinimum character count
maxLength / maxLengthMessagenumber / stringMaximum character count
pattern / patternMessagestring / stringRegex pattern (as string)
format / formatMessage'email' | 'url' | 'uuid' | 'cuid' | 'emoji' / stringBuilt-in format check
Number rules — for number, slider, range
PropertyTypeDescription
min / minMessagenumber / stringMinimum value
max / maxMessagenumber / stringMaximum value
integer / integerMessageboolean / stringMust be a whole number
positive / positiveMessageboolean / stringMust be positive
Array rules — for checkbox-group, switch-group, button-checkbox, button-card
PropertyTypeDescription
minItems / minItemsMessagenumber / stringMinimum selected items
maxItems / maxItemsMessagenumber / stringMaximum selected items
Boolean rules — for checkbox, switch
PropertyTypeDescription
mustBeTrue / mustBeTrueMessageboolean / stringMust be checked/true
Date rules — for date, daterange
PropertyTypeDescription
minDate / minDateMessagestring / stringEarliest allowed date (ISO string)
maxDate / maxDateMessagestring / stringLatest allowed date (ISO string)
mustBeFuture / mustBeFutureMessageboolean / stringDate must be in the future
mustBePast / mustBePastMessageboolean / stringDate 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" } }
modeFirst 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 TypesBase Zod Type
text, email, tel, textarea, select, radio, combobox, etc.z.string()
number, slider, rangez.number()
checkbox-group, switch-group, button-checkbox, button-cardz.array(z.string())
checkbox, switchz.boolean()
date, daterangez.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" } }
  ]
}
TypeChecksError attaches to
matchesvalue equals another field (confirm-email). Skips when empty — pair with requiredfield
requiredIffield required when the when group matchesfield
comparenumeric/date comparison between two fields (kind: 'number' | 'date'). Skips when an operand is missingfield
errorIfgeneric escape hatch: group matches ⇒ errorthen.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';