Validation

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

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 address[email protected]
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.

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 and repeaters resolve to z.any() server-side (the payload carries URLs/arrays, not File objects). 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';