Типизация правил

Валидационные правила в системе Vest строятся как декларативные функции, объединённые в тестовые сценарии (suite). При использовании JavaScript типизация часто остаётся неявной, однако при переходе на TypeScript становится критически важной для предотвращения ошибок на уровне компиляции, особенно в сложных формах, где набор правил зависит от состояния данных.

Типизация правил решает три ключевые задачи: предсказуемость входных данных, строгую связь между полями и их валидаторами, а также корректное определение результата проверки без необходимости ручного приведения типов.


Базовая форма правила и её тип

Каждое правило в Vest по сути представляет функцию, принимающую значение и возвращающую результат проверки. В простейшем виде это можно выразить так:

type Rule<T> = (value: T) => boolean;

Однако такая модель слишком упрощена, поскольку в реальной системе валидации требуется учитывать контекст ошибки, вложенные проверки и асинхронные сценарии.

Более приближённый к практике вариант:

type RuleResult = true | string;

type Rule<T> = (value: T) => RuleResult;

Здесь строка интерпретируется как сообщение об ошибке, а true означает успешное прохождение проверки.


Интеграция типов с тестовыми сценариями

В Vest правила обычно объединяются в тесты внутри suite. Типизация должна сохранять связь между полем и его типом.

type FormSchema = {
  username: string;
  age: number;
};

Далее определяется тип теста:

type TestFn<T> = (value: T) => void;

Однако в контексте Vest тест не просто функция, а декларация, которая регистрируется в suite, поэтому добавляется контекст выполнения:

type TestContext<T> = {
  value: T;
  field: string;
};

И итоговая сигнатура:

type VestTest<T> = (ctx: TestContext<T>) => RuleResult;

Типизация suite и связь с формой

Главная сложность заключается в том, чтобы связать структуру формы с набором правил.

type Suite<T> = {
  [K in keyof T]: VestTest<T[K]>[];
};

Такое отображение гарантирует, что для каждого поля формы будет применён корректный тип значения.

Пример использования:

const suite: Suite<FormSchema> = {
  username: [
    ({ value }) => value.length > 3 || "Слишком короткое имя"
  ],
  age: [
    ({ value }) => value >= 18 || "Возраст должен быть не менее 18"
  ]
};

Выведение типов результата валидации

Система Vest возвращает результат проверки, который также должен быть типизирован.

type FieldResult = {
  valid: boolean;
  errors: string[];
};

Для всей формы:

type ValidationResult<T> = {
  [K in keyof T]: FieldResult;
};

Это позволяет статически гарантировать, что результат валидации полностью соответствует структуре входных данных.


Типизация асинхронных правил

Асинхронные проверки — частый сценарий (например, проверка уникальности username через API). В Vest это требует расширения базового типа правила.

type AsyncRule<T> = (value: T) => Promise<RuleResult> | RuleResult;

Для унификации:

type MaybePromise<T> = T | Promise<T>;
type AsyncVestTest<T> = (ctx: TestContext<T>) => MaybePromise<RuleResult>;

Типизация suite должна учитывать оба варианта:

type AnyVestTest<T> = VestTest<T> | AsyncVestTest<T>;

Уточнение типов через пользовательские проверки

При создании кастомных правил важно сохранять типовую строгость. Например, правило проверки строки на формат email:

type Email = string & { __brand: "email" };

Функция-валидатор:

const isEmail = (value: string): value is Email => {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
};

И типизированное правило:

const emailRule: VestTest<string> = ({ value }) =>
  isEmail(value) || "Некорректный email";

Такой подход позволяет постепенно вводить номинальные типы поверх структурных.


Вывод типов из схемы формы

При масштабировании логики важно автоматизировать построение типов.

type InferSuiteResult<T> = ValidationResult<T>;

Для строгой связи можно использовать mapped types:

type InferRules<T> = {
  [K in keyof T]: AnyVestTest<T[K]>[];
};

Это создаёт единый контракт между данными и правилами проверки.


Типизация контекста выполнения

Внутри Vest каждый тест получает контекст выполнения, который может включать дополнительные поля: зависимости, состояние других полей формы, флаги выполнения.

type ExtendedContext<T, K extends keyof T> = {
  value: T[K];
  field: K;
  get: <P extends keyof T>(field: P) => T[P];
};

Это позволяет безопасно обращаться к связанным значениям:

const passwordRule: VestTest<FormSchema["password"]> = ({ value, get }) =>
  value === get("confirmPassword") || "Пароли не совпадают";

Ограничение побочных эффектов через типы

Типизация также помогает контролировать побочные эффекты. Если правило должно быть чистым:

type PureRule<T> = (value: T) => RuleResult;

Если допускается асинхронность или обращение к внешнему состоянию:

type EffectRule<T> = (ctx: TestContext<T>) => MaybePromise<RuleResult>;

Разделение таких типов позволяет явно различать уровни сложности правил и управлять архитектурой валидации.


Композиция типизированных правил

Типы должны поддерживать композицию:

type ComposedRule<T> = PureRule<T> | EffectRule<T>;

Функции комбинирования сохраняют тип:

const and =
  <T>(...rules: ComposedRule<T>[]): ComposedRule<T> =>
  (ctx) => {
    for (const rule of rules) {
      const result = typeof rule === "function" ? rule(ctx as any) : true;
      if (result !== true) return result;
    }
    return true;
  };

Статическая гарантия целостности схемы

Наиболее важный эффект типизации в Vest заключается в том, что структура формы, набор правил и результат валидации начинают образовывать замкнутую систему типов. Любое несоответствие между полем и его проверкой выявляется на этапе компиляции, а не во время выполнения.

Такой подход уменьшает количество скрытых ошибок и делает систему валидации предсказуемой даже при значительном росте сложности бизнес-логики.