# Validation

URL: https://docs.forms.saastro.io/docs/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:

| 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:

```tsx

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:

```tsx
.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:

```tsx

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:

```tsx
// 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](#escape-hatch-raw-zod-schemas)) 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

<details>
<summary><strong>General</strong></summary>

| Property          | Type      | Description                     |
| ----------------- | --------- | ------------------------------- |
| `required`        | `boolean` | Field must have a value         |
| `requiredMessage` | `string`  | Custom required error message   |
| `preset`          | `string`  | Apply a named validation preset |

</details>

<details>
<summary><strong>String rules</strong> — for text, email, tel, textarea, select, radio, combobox, etc.</summary>

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

</details>

<details>
<summary><strong>Number rules</strong> — for number, slider, range</summary>

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

</details>

<details>
<summary><strong>Array rules</strong> — for checkbox-group, switch-group, button-checkbox, button-card</summary>

| Property                       | Type                | Description            |
| ------------------------------ | ------------------- | ---------------------- |
| `minItems` / `minItemsMessage` | `number` / `string` | Minimum selected items |
| `maxItems` / `maxItemsMessage` | `number` / `string` | Maximum selected items |

</details>

<details>
<summary><strong>Boolean rules</strong> — for checkbox, switch</summary>

| Property                           | Type                 | Description          |
| ---------------------------------- | -------------------- | -------------------- |
| `mustBeTrue` / `mustBeTrueMessage` | `boolean` / `string` | Must be checked/true |

</details>

<details>
<summary><strong>Date rules</strong> — for date, daterange</summary>

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

</details>

---

## 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:

```tsx

.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](/docs/conditional-logic).)

### Any Standard Schema (Valibot, ArkType, Zod 4)

A field `schema` accepts any [Standard Schema](https://standardschema.dev) — 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.

```ts

.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:

```tsx

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](/docs/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](/docs/i18n).
- If you drive the form engine yourself with [`useFormState`](/docs/use-form-state), 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):

```tsx
const config = FormBuilder.create('signup')
  .validation({ mode: 'onTouched' })
  // ...fields/steps
  .build();
```

```json
{ "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`](/docs/use-form-state). 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).

```ts

// 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](#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`](/docs/conditional-logic) as conditional visibility:

```json
{
  "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):

```tsx
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](/docs/plugins#custom-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

```tsx
  ValidationRules,
  SchemaType,
  ValidationPresetMeta,
  ValidatableForm,
  FormValidationResult,
  StandardSchemaV1,
} from '@saastro/forms';
  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';
```
