Анализ схем в runtime

Проверка данных во время выполнения — одна из ключевых задач при работе с формами, API, пользовательским вводом и динамическими структурами объектов. В экосистеме React наиболее распространённой связкой является библиотека Yup совместно с React Hook Form и адаптером @hookform/resolvers, внутри которого используется yupResolver.

yupResolver выполняет роль промежуточного слоя между системой валидации Yup и механизмом управления формой React Hook Form. Основная задача резолвера — запуск анализа схемы в runtime и преобразование результата проверки в формат, понятный React Hook Form.


Архитектура runtime-валидации

Во время выполнения приложения данные проходят несколько стадий обработки:

  1. Получение исходных значений формы
  2. Передача данных в yupResolver
  3. Анализ схемы Yup
  4. Преобразование ошибок
  5. Возврат результата в React Hook Form

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

form values
    ↓
yupResolver
    ↓
schema.validate()
    ↓
ValidationError | validated data
    ↓
React Hook Form

Принцип работы yupResolver

Базовый пример подключения:

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";

const schema = yup.object({
  email: yup.string().email().required(),
  age: yup.number().min(18)
});

const form = useForm({
  resolver: yupResolver(schema)
});

Внутри yupResolver вызывается метод:

schema.validate(values, options)

или:

schema.validateSync(values, options)

в зависимости от режима работы.


Runtime-анализ схемы

Yup анализирует схему динамически во время выполнения приложения. Это означает:

  • структура проверяется не на этапе компиляции;
  • схема может изменяться динамически;
  • валидация зависит от текущего состояния данных;
  • возможно условное поведение.

Пример динамического анализа:

const schema = yup.object({
  role: yup.string().required(),

  permissions: yup.array().when("role", {
    is: "admin",
    then: (schema) => schema.min(1).required(),
    otherwise: (schema) => schema.notRequired()
  })
});

Во время runtime Yup:

  1. считывает значение role;
  2. определяет активную ветку схемы;
  3. строит итоговую структуру проверки;
  4. запускает валидаторы.

validate и validateSync

Асинхронная проверка

await schema.validate(data);

Используется по умолчанию в yupResolver.

Подходит для:

  • запросов к API;
  • асинхронных кастомных тестов;
  • сложных вычислений.

Синхронная проверка

schema.validateSync(data);

Работает быстрее, но не поддерживает async-тесты.


Runtime-опции валидации

abortEarly

По умолчанию Yup прекращает проверку после первой ошибки.

schema.validate(data, {
  abortEarly: true
});

Для получения всех ошибок:

schema.validate(data, {
  abortEarly: false
});

В React Hook Form обычно используется именно этот режим.


stripUnknown

Удаляет поля, отсутствующие в схеме.

const schema = yup.object({
  name: yup.string()
});

const result = await schema.validate(
  {
    name: "Alex",
    secret: "hidden"
  },
  {
    stripUnknown: true
  }
);

console.log(result);

Результат:

{
  name: "Alex"
}

recursive

Управляет глубиной анализа вложенных объектов.

schema.validate(data, {
  recursive: false
});

При отключении вложенные структуры не проверяются.


strict

Отключает автоматическое преобразование типов.

const schema = yup.number();

await schema.validate("42");

Результат:

42

В strict-режиме:

await schema.validate("42", {
  strict: true
});

Ошибка:

this must be a `number` type

Механизм cast

Перед основной проверкой Yup выполняет трансформацию значений.

const schema = yup.number();

const value = schema.cast("100");

console.log(value);

Результат:

100

Это важнейшая часть runtime-анализа.

Этапы обработки

raw value
   ↓
transform
   ↓
cast
   ↓
validation

Runtime-трансформации

transform

const schema = yup.string().transform((value) => {
  return value.trim();
});

Каждое значение проходит через pipeline трансформаций.


Комплексная обработка

const schema = yup.string()
  .transform((value) => value.trim())
  .transform((value) => value.toLowerCase());

Runtime-цепочка:

"  ADMIN  "
     ↓
trim()
     ↓
"ADMIN"
     ↓
toLowerCase()
     ↓
"admin"

Анализ вложенных объектов

Nested object validation

const schema = yup.object({
  user: yup.object({
    profile: yup.object({
      name: yup.string().required()
    })
  })
});

Yup рекурсивно обходит структуру:

root
 └── user
      └── profile
           └── name

Анализ массивов

Array schema

const schema = yup.array(
  yup.object({
    title: yup.string().required()
  })
);

Во время runtime Yup:

  1. проверяет тип массива;
  2. итерирует элементы;
  3. применяет дочернюю схему;
  4. агрегирует ошибки.

Проверка индексов

[
  { title: "A" },
  { title: "" }
]

Ошибка:

[1].title is a required field

ValidationError

При ошибке Yup генерирует объект ValidationError.

Структура:

{
  name: "ValidationError",
  path: "email",
  message: "email is required",
  errors: [],
  inner: []
}

Поле inner

При abortEarly: false Yup собирает полный список ошибок.

try {
  await schema.validate(data, {
    abortEarly: false
  });
} catch (error) {
  console.log(error.inner);
}

Пример:

[
  {
    path: "email",
    message: "Invalid email"
  },
  {
    path: "password",
    message: "Too short"
  }
]

Преобразование ошибок в yupResolver

React Hook Form использует собственный формат ошибок.

yupResolver преобразует:

ValidationError

в:

{
  email: {
    type: "email",
    message: "Invalid email"
  }
}

Алгоритм преобразования ошибок

Внутренне резолвер:

for (const error of validationError.inner) {
  errors[error.path] = {
    type: error.type,
    message: error.message
  };
}

Runtime-контекст

Yup поддерживает контекст выполнения.

const schema = yup.object({
  price: yup.number().test(
    "max-price",
    "Too expensive",
    function(value) {
      return value <= this.options.context.maxPrice;
    }
  )
});

Передача контекста:

yupResolver(schema, {
  context: {
    maxPrice: 1000
  }
});

this в кастомных тестах

Внутри test() доступен специальный runtime-контекст.

test(function(value) {
  console.log(this.path);
  console.log(this.parent);
  console.log(this.options);
});

Runtime-зависимости между полями

ref

const schema = yup.object({
  password: yup.string().required(),

  confirmPassword: yup.string()
    .oneOf(
      [yup.ref("password")],
      "Passwords mismatch"
    )
});

Во время проверки Yup:

  1. получает текущее значение password;
  2. разрешает ссылку;
  3. сравнивает значения.

Lazy schema

lazy() создаёт схему динамически.

const schema = yup.lazy((value) => {
  if (typeof value === "string") {
    return yup.string();
  }

  return yup.number();
});

Runtime-процесс:

incoming value
      ↓
lazy resolver
      ↓
dynamic schema
      ↓
validation

Полиморфные структуры

Dynamic object validation

const schema = yup.object({
  type: yup.string().required(),

  payload: yup.lazy((value, options) => {
    const type = options.parent.type;

    if (type === "email") {
      return yup.object({
        email: yup.string().email()
      });
    }

    return yup.object({
      phone: yup.string()
    });
  })
});

Runtime-композиция схем

concat

const baseSchema = yup.object({
  id: yup.number().required()
});

const extendedSchema = baseSchema.concat(
  yup.object({
    title: yup.string().required()
  })
);

Во время выполнения Yup объединяет AST схем.


describe()

Метод describe() позволяет получить runtime-описание схемы.

const description = schema.describe();

console.log(description);

Результат:

{
  type: "object",
  fields: {
    email: {
      type: "string"
    }
  }
}

Runtime-интроспекция

describe() полезен для:

  • генерации UI;
  • динамических форм;
  • сериализации схем;
  • документации;
  • визуальных редакторов.

reach()

Позволяет извлекать часть схемы.

const nameSchema = yup.reach(
  schema,
  "user.profile.name"
);

Частичная валидация

validateAt

await schema.validateAt(
  "user.email",
  data
);

Проверяется только конкретное поле.


Runtime-оптимизация

Кэширование схем

Плохая практика:

function Component() {
  const schema = yup.object({
    name: yup.string()
  });
}

Схема пересоздаётся при каждом рендере.

Оптимизация:

const schema = yup.object({
  name: yup.string()
});

или:

const schema = useMemo(() => {
  return yup.object({
    name: yup.string()
  });
}, []);

Стоимость runtime-анализа

На производительность влияют:

  • глубина вложенности;
  • количество when;
  • число transform;
  • объём массивов;
  • async-тесты;
  • recursive traversal.

expensive validation

Пример тяжёлой проверки:

const schema = yup.array(
  yup.object({
    items: yup.array(
      yup.object({
        name: yup.string().required()
      })
    )
  })
);

Глубокие структуры увеличивают:

  • число обходов;
  • объём аллокаций;
  • количество ValidationError.

Асинхронные тесты

test()

const schema = yup.string().test(
  "unique-email",
  "Email already exists",
  async (value) => {
    const exists = await api.checkEmail(value);

    return !exists;
  }
);

Во время runtime Yup:

  1. ждёт Promise;
  2. агрегирует результаты;
  3. формирует итоговую ошибку.

Порядок выполнения тестов

yup.string()
  .required()
  .min(5)
  .matches(/[A-Z]/)

Runtime-цепочка:

required
   ↓
min
   ↓
matches

Runtime-cancellation

Yup не поддерживает встроенную отмену async-проверок.

Проблема:

user typing
    ↓
multiple async validations
    ↓
race conditions

Особенно критично при:

  • debounce;
  • live validation;
  • API requests.

Взаимодействие с React Hook Form

yupResolver вызывается:

  • при submit;
  • при change;
  • при blur;
  • при trigger();
  • при reset();
  • при reValidateMode.

Resolver contract

React Hook Form ожидает:

{
  values,
  errors
}

Успешная проверка:

{
  values: validatedValues,
  errors: {}
}

Ошибка:

{
  values: {},
  errors: {
    email: {
      message: "Required"
    }
  }
}

sync vs async resolver

Async

resolver: yupResolver(schema)

Sync

resolver: yupResolver(schema, {}, {
  mode: "sync"
})

Runtime type coercion

Yup активно приводит типы.

const schema = yup.boolean();

schema.cast("true");

Результат:

true

Особенности number()

yup.number().cast("");

Результат:

NaN

Это важная runtime-особенность Yup.


nullable()

yup.string().nullable();

Разрешает:

null

но не:

undefined

optional()

yup.string().optional();

Разрешает отсутствие значения.


defined()

yup.string().defined();

Запрещает:

undefined

Runtime-проверка enum

yup.string().oneOf([
  "admin",
  "user",
  "guest"
]);

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


notOneOf

yup.string().notOneOf([
  "root",
  "system"
]);

Runtime regex validation

yup.string().matches(
  /^[A-Z]{3}\d+$/
);

Проверка происходит через стандартный RegExp.test().


Метаданные схем

meta()

const schema = yup.string().meta({
  placeholder: "Enter email"
});

Получение:

schema.describe().meta

Runtime default values

const schema = yup.object({
  role: yup.string().default("user")
});
schema.getDefault();

Результат:

{
  role: "user"
}

clone()

const cloned = schema.clone();

Создаёт независимую копию runtime-схемы.


Внутреннее устройство Yup

Yup хранит:

  • transforms;
  • tests;
  • conditions;
  • deps;
  • flags;
  • metadata.

Каждая схема представляет собой объект с цепочкой runtime-конфигурации.


Runtime pipeline Yup

Общий процесс:

input
  ↓
cast()
  ↓
transform()
  ↓
type check
  ↓
conditions
  ↓
tests
  ↓
ValidationError | success

Runtime-анализ условных схем

when()

yup.string().when("role", {
  is: "admin",
  then: (schema) => schema.required()
});

Во время выполнения Yup:

  1. находит зависимость;
  2. получает значение поля;
  3. пересобирает схему;
  4. запускает проверку.

Циклические зависимости

Некорректная схема:

fieldA.when("fieldB")
fieldB.when("fieldA")

Может приводить к рекурсивным проблемам анализа.


Runtime-debugging

validateSyncAt

schema.validateSyncAt(
  "user.name",
  data
);

Полезно при локализации ошибок.


Диагностика describe()

console.log(
  JSON.stringify(
    schema.describe(),
    null,
    2
  )
);

Позволяет исследовать итоговую runtime-структуру.


Runtime-совместимость с TypeScript

Yup выполняет проверку только в runtime.

TypeScript работает исключительно во время компиляции.

Поэтому возможна ситуация:

type User = {
  age: number;
}

и:

{
  age: "wrong"
}

TypeScript не предотвратит ошибку при получении внешних данных.

Runtime-валидация Yup решает эту проблему.


InferType

type User = yup.InferType<typeof schema>;

Тип выводится из runtime-схемы.


Ограничения runtime-подхода

Yup имеет ряд архитектурных ограничений:

  • слабая типобезопасность;
  • высокая стоимость deeply nested validation;
  • runtime-overhead;
  • сложность async orchestration;
  • отсутствие compile-time guarantees.

Практическая структура enterprise-схем

Крупные приложения обычно разделяют:

schemas/
  auth/
  profile/
  billing/
  admin/

и создают:

  • базовые схемы;
  • композиционные схемы;
  • lazy-схемы;
  • conditional schemas;
  • reusable validators.

Переиспользуемые runtime-валидаторы

const phoneValidator = yup.string()
  .matches(/^\+\d+$/);

const userSchema = yup.object({
  phone: phoneValidator
});

Factory-подход

function createUserSchema(options) {
  return yup.object({
    name: yup.string()
      .max(options.maxNameLength)
  });
}

Runtime-конфигурация позволяет создавать динамические схемы под разные окружения.


Анализ поведения resolver mode

React Hook Form поддерживает разные режимы:

useForm({
  mode: "onChange"
});

В этом случае yupResolver вызывается при каждом изменении значения.

Последствия:

  • высокая нагрузка;
  • частые async-вызовы;
  • повторный runtime-анализ;
  • возможные race conditions.

Оптимизация больших форм

Для снижения нагрузки применяют:

  • validateAt;
  • debounce;
  • memoization;
  • schema splitting;
  • lazy validation;
  • selective rendering.

Runtime-анализ и серверная валидация

Yup может использоваться:

  • в браузере;
  • в Node.js;
  • в server actions;
  • в middleware;
  • в API handlers.

Одна схема может работать в нескольких runtime-средах.


Унификация frontend/backend validation

Общая схема:

shared/
  validation/
    userSchema.js

Используется одновременно:

  • в React;
  • в Express;
  • в Next.js API routes;
  • в микросервисах.

Runtime-ошибки схем

Некорректные схемы тоже могут генерировать ошибки выполнения.

Пример:

yup.object({
  age: yup.number().min("wrong")
});

Ошибка возникает непосредственно в runtime.


Кастомные типы

addMethod

yup.addMethod(
  yup.string,
  "isHexColor",
  function() {
    return this.matches(
      /^#([0-9A-F]{3}){1,2}$/i
    );
  }
);

Использование:

yup.string().isHexColor();

Runtime-расширение Yup

Механизм addMethod() изменяет prototype-систему Yup во время выполнения приложения.

Это позволяет:

  • расширять DSL;
  • создавать domain-specific validators;
  • внедрять shared behavior.

Внутренний lifecycle yupResolver

Полный lifecycle:

form event
   ↓
resolver execution
   ↓
schema cast
   ↓
condition resolving
   ↓
transform pipeline
   ↓
validation tests
   ↓
ValidationError mapping
   ↓
React Hook Form state update