rilaykit
Core concepts

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.

ValidatorValidatesDefault message
required(message?)Non-empty (rejects '', null, undefined, [], {})This field is required
email(message?)Email formatPlease enter a valid email address
url(message?)URL format (native URL constructor)Please enter a valid URL
minLength(min, message?)Minimum string lengthMust be at least {min} characters long
maxLength(max, message?)Maximum string lengthMust be no more than {max} characters long
pattern(regex, message?)Regular expression matchValue 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) => booleanValidation 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:

OptionValuesDefaultControls
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 with FORM_LEVEL_ERROR_CODE).
  • An empty or unmatched path lands in the reserved __form__ bucket. Read it with useFormErrors() (from @rilaykit/forms/react or rilaykit/react) to render a form-level error banner.
  • isValid counts 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');     // false

combineSchemas(...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));

On this page