Validation
Built-in validators and Standard Schema support for type-safe form validation.
RilayKit's validation engine implements Standard Schema: use the built-in validators or any compliant library (Zod 3.24+, Valibot 1.0+, ArkType 2.0+) interchangeably, without adapters.
Built-in Validators
All built-ins are imported from @rilaykit/core. Each accepts an optional custom message and returns a StandardSchemaV1 object.
| Validator | Validates | Default message |
|---|---|---|
required(message?) | Non-empty (rejects '', null, undefined, [], {}) | This field is required |
email(message?) | Email format | Please enter a valid email address |
url(message?) | URL format (native URL constructor) | Please enter a valid URL |
minLength(min, message?) | Minimum string length | Must be at least {min} characters long |
maxLength(max, message?) | Maximum string length | Must be no more than {max} characters long |
pattern(regex, message?) | Regular expression match | Value does not match required pattern |
number(message?) | Valid number (coerces strings) | Must be a valid number |
min(minValue, message?) | Minimum numeric value (coerces strings) | Must be at least {minValue} |
max(maxValue, message?) | Maximum numeric value (coerces strings) | Must be no more than {maxValue} |
custom<T>(fn, message?) | Sync predicate (value) => boolean | Validation failed |
async<T>(fn, message?) | Async predicate (value) => Promise<boolean> | Async validation failed |
combine<T>(...schemas) | Runs schemas in sequence, accumulates all issues | — |
import { required, minLength, custom, async } from '@rilaykit/core';
required('Please fill this in');
minLength(8, 'Password is too short');
custom<string>((value) => value.startsWith('SK_'), 'Must start with SK_');
async<string>(async (value) => (await fetch(`/api/check?name=${value}`)).ok, 'Already taken');Standard Schema Support
Any library implementing StandardSchemaV1 works directly — Zod (3.24+), Valibot (1.0+), and ArkType (2.0+) all support it natively. Built-ins and third-party schemas mix freely in the same validate array; they run in order and all issues accumulate.
import { required, minLength } from '@rilaykit/core';
import { form } from '@rilaykit/forms';
import { z } from 'zod';
const profileForm = form.create(rilay, 'profile').add({
id: 'username',
type: 'input',
props: { label: 'Username' },
validation: {
validate: [
required(), // RilayKit built-in
minLength(3), // RilayKit built-in
z.string().regex(/^[a-z0-9]+$/), // Zod schema
// v.pipe(v.string(), v.email()) // Valibot works too
// type('string.email') // ...and ArkType
],
},
});The validation property is always an object with a validate key -- never a bare array. Use { validate: [...] }, not [...].
Validation Timing
Timing is configured form-level via setValidation(), mirroring React Hook Form's two-phase model:
| Option | Values | Default | Controls |
|---|---|---|---|
mode | 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all' | 'onTouched' | When a field first validates |
reValidateMode | 'onChange' | 'onBlur' | 'onSubmit' | 'onChange' | When a field re-validates once it has errored |
The defaults mean: a field validates on its first blur, then live -- and once it shows an error, the error clears live as the user types the fix. (Note: RHF's own mode default is onSubmit; RilayKit keeps its historical onTouched.)
Submit always validates everything and marks errored fields touched, so touched-gated renderers show submit errors immediately.
const contactForm = form.create(rilay, 'contact')
.add({
id: 'email',
type: 'input',
props: { label: 'Email' },
validation: {
validate: [required(), email()],
debounceMs: 300, // per-field async cost control
},
})
.setValidation({ mode: 'onBlur', reValidateMode: 'onChange' });Field-Level: FieldValidationConfig
interface FieldValidationConfig<T = any> {
/** One or more Standard Schema validators */
validate?: StandardSchema<T> | StandardSchema<T>[];
/** Debounce change-triggered validation (async cost control).
Blur and submit always validate immediately. */
debounceMs?: number;
}Form-Level Validation
For cross-field rules (e.g. "confirm password must match"), pass validate to setValidation(). Form-level schemas receive the entire form data.
import { z } from 'zod';
import { form } from '@rilaykit/forms';
const passwordSchema = z
.object({
password: z.string().min(8, 'Password too short'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // routes the error to that field
});
const changePasswordForm = form.create(rilay, 'change-password')
.add({ id: 'password', type: 'password', props: { label: 'New Password' } })
.add({ id: 'confirmPassword', type: 'password', props: { label: 'Confirm Password' } })
.setValidation({ validate: passwordSchema });Error routing
Form-level issues are written to the error store, routed by issue.path:
- A path naming a known field attaches the error to that field --
useFieldErrors('confirmPassword')receives it (tagged withFORM_LEVEL_ERROR_CODE). - An empty or unmatched path lands in the reserved
__form__bucket. Read it withuseFormErrors()(from@rilaykit/forms/reactorrilaykit/react) to render a form-level error banner. isValidcounts the whole map,__form__included.
import { useFormErrors } from '@rilaykit/forms/react';
function FormErrorBanner() {
const errors = useFormErrors();
if (errors.length === 0) return null;
return <div role="alert">{errors.map((e) => e.message).join(' ')}</div>;
}Form-level rules re-run on the same mode/reValidateMode cadence as fields, and on repeatable row add/remove/move -- cross-field errors appear and clear live, no resubmit needed. The constants FORM_LEVEL_ERROR_KEY ('__form__') and FORM_LEVEL_ERROR_CODE ('FORM_LEVEL') are exported from @rilaykit/core.
Repeatable composite ids (items[k0].price) never match a schema's dot path (items.0.price), so cross-field issues over repeatable rows land in __form__.
Utility Functions
isStandardSchema(value)
Type guard for the Standard Schema interface.
import { isStandardSchema } from '@rilaykit/core';
isStandardSchema(required()); // true
isStandardSchema(z.string().email()); // true
isStandardSchema('not a schema'); // falsecombineSchemas(...schemas)
Merges multiple Standard Schema objects into one -- useful where a single schema is expected instead of an array. Equivalent to combine() from the validators; use whichever import reads clearest.
import { combineSchemas, required, email } from '@rilaykit/core';
const emailSchema = combineSchemas(required('Email is required'), email(), z.string().min(5));