Кастомные валидаторы
Когда встроенных фабрик недостаточно, правило описывается обычной функцией. Кастомное правило
кладётся в тот же массив, что и встроенные фабрики — вторым аргументом оператора validate(sig, [rules])
внутри схемы defineValidationSchema. Схема валидации живёт отдельно от layout (RenderNode / JSON):
она не несёт компонентов, а прогоняется по требованию раннером validateModel(model, schema).
Контракт
Синхронное правило — функция типа Rule<TField>. Ментальная модель автора — (value) => error:
правило видит только значение поля и возвращает ValidationError ({ code, message, params?, severity? })
либо null, если значение валидно.
Тип Rule<TField> объявляет позиционные scope/root как never — это техническая деталь, благодаря
которой в один массив кладутся и value-only фабрики (required()/min()), и инлайн-правила (value) => ….
Использовать эти аргументы не нужно: правилу доступно только value. Всё, что зависит от соседних
полей, — это уже кросс-полевая проверка через оператор cross.
Пустые значения принято пропускать — обязательность закрывает required().
Инлайн-валидатор
Простое правило можно записать прямо в массиве оператора validate:
import { createModel } from '@reformer/core';
import { validate, defineValidationSchema, validateModel } from '@reformer/core/validation';
type AgeForm = { age: number | null };
const model = createModel<AgeForm>({ age: null });
const schema = defineValidationSchema<AgeForm>(({ model }) => {
validate(model.$.age, [
(value) =>
value != null && value < 18 ? { code: 'mustBeAdult', message: 'Только 18+' } : null,
]);
});
// Прогон по требованию: ошибки разносятся по нодам формы, возвращается флаг валидности
const ok = await validateModel(model, schema);
Переиспользуемая фабрика
Чтобы применять правило в нескольких формах, оформите его фабрикой, возвращающей Rule<T>.
Каждое правило возвращает одну ошибку — сложную проверку разбивают на несколько правил в массиве;
оператор validate выполняет их все и накапливает ошибки:
import type { Rule } from '@reformer/core/validation';
export const hasUppercase = (): Rule<string> => (value) =>
!value || /[A-Z]/.test(value) ? null : { code: 'noUppercase', message: 'Нужна заглавная буква' };
export const hasNumber = (): Rule<string> => (value) =>
!value || /[0-9]/.test(value) ? null : { code: 'noNumber', message: 'Нужна цифра' };
// Применение в схеме — внутри defineValidationSchema(({ model }) => { … }):
import { validate } from '@reformer/core/validation';
import { required, minLength } from '@reformer/core/validators';
import { hasUppercase, hasNumber } from './validators/password';
validate(model.$.password, [required(), minLength(8), hasUppercase(), hasNumber()]);
Валидатор с параметрами
Параметризуйте фабрику и прикладывайте данные ошибки через params — их можно использовать при
отображении:
import type { Rule } from '@reformer/core/validation';
export function range(min: number, max: number): Rule<number | null> {
return (value) => {
if (value == null) return null; // пропускаем пустое
if (value < min || value > max) {
return {
code: 'range',
message: `Допустимо от ${min} до ${max}`,
params: { min, max, actual: value },
};
}
return null;
};
}
// Внутри defineValidationSchema(({ model }) => { … }):
import { validate } from '@reformer/core/validation';
import { required } from '@reformer/core/validators';
import { range } from './validators/range';
validate(model.$.quantity, [required(), range(1, 100)]);
Кросс-полевые проверки
Правило, зависящее от другого поля, — это оператор cross(sig, fn). Функция fn получает снапшот
модели текущего scope (model.get()) и читает соседние поля прямо из объекта, а ошибку cross
вешает на переданный сигнал sig — то поле, которое должно её нести:
import { createModel, type ValidationError } from '@reformer/core';
import { validate, cross, defineValidationSchema, validateModel } from '@reformer/core/validation';
import { required } from '@reformer/core/validators';
type PasswordForm = { password: string; confirmPassword: string };
const model = createModel<PasswordForm>({ password: '', confirmPassword: '' });
// confirmPassword должен совпадать с password — правило читает снапшот всей формы
const passwordsMatch = (f: PasswordForm): ValidationError | null =>
f.confirmPassword && f.password && f.confirmPassword !== f.password
? { code: 'passwordMismatch', message: 'Пароли не совпадают' }
: null;
const schema = defineValidationSchema<PasswordForm>(({ model }) => {
validate(model.$.password, [required()]);
validate(model.$.confirmPassword, [required()]);
// ошибку несёт confirmPassword — на его сигнал и вешаем cross
cross(model.$.confirmPassword, passwordsMatch);
});
const ok = await validateModel(model, schema);
Прогон валидации сам по себе не реактивен. Чтобы confirmPassword перепроверялся при изменении
password, свяжите поля через revalidateWhen в behavior — он вызовет validateModel заново.
См. Стратегии валидации.
Условная валидация
Правила, действующие только в части формы, оборачиваются оператором validateWhen(cond, cb). Пока
cond() истинно, вложенные правила активны; когда ложно — они не выполняются, а ошибки их полей
гасятся (поле получает setErrors([])), так что «прятать» лишние ошибки в UI не нужно:
import { createModel } from '@reformer/core';
import {
validate,
validateWhen,
defineValidationSchema,
validateModel,
} from '@reformer/core/validation';
import { required } from '@reformer/core/validators';
type TaxForm = { country: string; taxId: string };
const model = createModel<TaxForm>({ country: '', taxId: '' });
const isNineDigits = (value: string) =>
/^\d{9}$/.test(value) ? null : { code: 'invalidTaxId', message: 'ИНН — 9 цифр' };
const schema = defineValidationSchema<TaxForm>(({ model }) => {
validate(model.$.country, [required()]);
// ИНН обязателен только для страны US; иначе правила ветки гасятся
validateWhen(
() => model.country === 'US',
() => validate(model.$.taxId, [required(), isNineDigits])
);
});
const ok = await validateModel(model, schema);
Условие cond читает значения через value-proxy модели (model.country), поэтому в момент прогона
validateModel учитывается актуальное состояние формы.
Валидация элементов массива
Для валидации элементов массива служит оператор each(arr, itemFn). Он проходит по каждому элементу
массива модели и вызывает itemFn с под-моделью элемента FormModel<Item>, внутри которой применяются
обычные операторы validate/cross:
import { createModel, type FormModel } from '@reformer/core';
import { validate, each, defineValidationSchema, validateModel } from '@reformer/core/validation';
import { required, email } from '@reformer/core/validators';
type ContactForm = { emails: { address: string }[] };
const model = createModel<ContactForm>({ emails: [{ address: '' }] });
// Под-схема одного элемента массива — правила по под-модели элемента
const emailItem = (im: FormModel<{ address: string }>): void => {
validate(im.$.address, [required(), email()]);
};
const schema = defineValidationSchema<ContactForm>(({ model }) => {
each(model.emails, emailItem);
});
const ok = await validateModel(model, schema);
Для кросс-полевой проверки внутри элемента захватите его снапшот в замыкание (const item = im.get())
и передайте в cross — так cross увидит данные элемента, а не корня формы.
Предупреждения вместо ошибок
Ошибка с severity: 'warning' показывается пользователю, но не блокирует submit — при наличии
только предупреждений validateModel возвращает true. Используйте для «мягких» подсказок:
import type { Rule } from '@reformer/core/validation';
const weakButAllowed: Rule<string> = (value) =>
value && value.length < 12
? { code: 'shortPassword', message: 'Рекомендуем 12+ символов', severity: 'warning' }
: null;
// Подключается как обычное правило:
import { validate } from '@reformer/core/validation';
import { required, minLength } from '@reformer/core/validators';
validate(model.$.password, [required(), minLength(8), weakButAllowed]);
Советы
- Возвращайте
nullдля валидного значения — неundefinedи не{}. - Пропускайте пустое (
if (!value) return null) — обязательность закрываетrequired(). - Значения — через
validate, зависимости — черезcross. ПравилоRule<T>видит толькоvalue; всё, что читает соседние поля, выносите вcross(sig, (f) => …)по снапшоту. - Используйте описательные
code(passwordTooWeak, а неinvalid) — по ним удобно фильтровать и локализовать ошибки. - Кладите контекст в
params({ max: 100, actual: value.length }) — пригодится при отображении.
Дальше
- Асинхронная валидация — проверки уникальности через сервер.
- Стратегии валидации — пошаговые формы, зависимые поля, момент запуска.
- Обработка ошибок — чтение и отображение
ValidationError.