Core concepts / Validation

Validation

14 built-in validation rule types, custom sync and async validators, and how validation triggers work.

Validation rules

Each field can have a validation array with one or more rules. Rules are evaluated in order — the first failure produces the error message.

{  id: 'email',  type: 'email',  label: 'Email',  required: true,  validation: [    { type: 'email', message: 'Enter a valid email address.' },  ],}

Built-in rule types

TypeParametersDescription
requiredmessage?Field must have a value. Usually set via required: true on the field instead.
minvalue: number, message?Numeric minimum
maxvalue: number, message?Numeric maximum
minLengthvalue: number, message?String minimum length
maxLengthvalue: number, message?String maximum length
patternregex: string, flags?: string, message?Regular expression match
emailmessage?Valid email format
phonemessage?Valid phone number format
urlmessage?Valid URL format
datemin?: string, max?: string, message?Date within range
fileSizemaxMb: number, message?Maximum file size in MB
fileTypeaccept: string[], message?Allowed MIME types

User-defined rule types

These are not built-in — they delegate to validator functions you provide.

TypeParametersDescription
customname: string, params?, message?Custom sync validator
asyncendpoint: string, message?Async server-side validator. Runs on blur and again as the submit gate.

Examples

String length

validation: [  { type: 'minLength', value: 2, message: 'Name must be at least 2 characters.' },  { type: 'maxLength', value: 100, message: 'Name must be under 100 characters.' },]

Regex pattern

validation: [  {    type: 'pattern',    regex: '^[A-Z]{3}-\\d{4}$',    message: 'Must match format: ABC-1234',  },]

Numeric bounds

validation: [  { type: 'min', value: 0, message: 'Amount cannot be negative.' },  { type: 'max', value: 10000, message: 'Maximum amount is 10,000.' },]

File validation

validation: [  { type: 'fileSize', maxMb: 5, message: 'File must be under 5 MB.' },  { type: 'fileType', accept: ['image/png', 'image/jpeg'], message: 'Only PNG and JPEG files are accepted.' },]

Custom validators

Register sync validators and reference them by name in the schema.

Define the validator:

lib/validators.ts
import type { CustomValidator } from '@squaredr/fieldcraft-core'export const validators: Record<string, CustomValidator> = {  noProfanity: (value, _values, _params) => {    const blocked = ['spam', 'test']    if (typeof value === 'string' && blocked.some((w) => value.toLowerCase().includes(w))) {      return 'Please remove inappropriate content.'    }    return null // null = valid  },  matchesField: (value, values, params) => {    const otherField = params?.field as string    if (value !== values[otherField]) {      return params?.message as string || 'Values must match.'    }    return null  },}

Reference it in the schema:

{  id: 'confirm_email',  type: 'email',  label: 'Confirm email',  validation: [    { type: 'custom', name: 'matchesField', params: { field: 'email', message: 'Emails must match.' } },  ],}

Pass validators to the renderer:

<FormEngineRenderer  schema={schema}  validators={validators}  onSubmit={handleSubmit}/>

The validator function signature is (value, allValues, params?) => string | null. Return a string to indicate an error, or null to pass.

Async validators

For server-side validation (e.g., checking if a username is taken):

{  id: 'username',  type: 'short_text',  label: 'Username',  validation: [    { type: 'async', endpoint: 'uniqueUsername' },  ],}

The endpoint is a key, not a URL. Register a function under that exact key — the name in the schema and the name in the registry must match:

const asyncValidators: Record<string, AsyncValidator> = {  uniqueUsername: async (value) => {    const res = await fetch(`/api/check-username?u=${value}`)    const data = await res.json()    return data.available ? null : 'Username is already taken.'  },}
<FormEngineRenderer  schema={schema}  asyncValidators={asyncValidators}  onSubmit={handleSubmit}/>

Keeping the function out of the schema is deliberate. The schema stays JSON — serialisable, storable in a database row, diffable in a pull request. The moment a function can live in it, it stops being data.

When async rules run

Twice, and the two differ on purpose.

WhenIf the validator throws
On blurAfter the synchronous rules passFails open — the field is left as the sync rules found it
On submitEvery visible field, including ones never blurredFails closed — the submission is blocked

Blur is a convenience: an unreachable endpoint should not nag a respondent about something they cannot fix. Submit is the gate: an unverified claim must not pass silently.

Async rules are skipped when the synchronous rules already failed — there is no point asking a server about a value minLength has rejected.

A blur result is reused by the submit gate when the value has not changed, so the same question is not asked twice. Change the value after blurring and it is re-checked.

The pending state

While a check is in flight the field is neither valid nor invalid — the answer is not known yet. FormState.validating carries that:

const { state } = useFormEngine(schema, { asyncValidators }){state.validating.username && <Spinner />}

Navigation refuses while a check is open. Without that, a respondent could leave a field, advance, and have the error land on a question they can no longer see.

debounceMs is deprecated and scheduled for removal on 2027-07-01. It has never had any effect: blur and submit are single events, so there is nothing to debounce.

When validation runs

TriggerWhat validatesAsync rules too?
Field blurThat single fieldYes, after the sync rules pass
setValue()That single field (if touched)No
Section navigation ("Next")All visible fields in the current sectionNo — but navigation refuses while a check is open
SubmitAll visible fields across all sectionsYes, including fields never blurred

Errors are only displayed after a field has been touched (the user interacted with it) or after submitAttempted is true. This prevents showing errors on fields the user hasn't reached yet.

Structural fields skip validation

Non-input field types (info_block, divider, spacer, section_header, page_break, welcome-screen, thank-you-screen, rich-text, image, video) are never validated. Adding required or validation to them will cause the schema validator to throw FormEngineSchemaError.

ValidationResult

The validate() and validateSection() engine methods return:

type ValidationResult = {  valid: boolean  errors: Record<string, string[]>  firstErrorFieldId?: string  firstErrorSectionId?: string}

Next steps