Расширение базового класса Schema

В основе библиотеки Yup лежит абстракция схемы валидации, реализованная через базовый класс Schema. Именно он формирует поведение всех производных типов: string, number, boolean, object, array, а также пользовательских схем. Архитектура библиотеки построена таким образом, что расширение функциональности чаще происходит не через классическое наследование, а через композицию, регистрацию методов и подключение кастомных проверок.

Роль базового класса Schema

Schema определяет общий контракт для всех типов валидации:

  • хранение цепочки правил (tests)
  • управление трансформациями входных данных (transforms)
  • обработка nullable/required состояния
  • механизм асинхронной и синхронной валидации
  • контекст выполнения (ValidationContext)

Каждая конкретная схема в Yup фактически является специализированной конфигурацией над этим базовым поведением.

Пример логики, характерной для любого типа:

import * as yup from 'yup';

const schema = yup.string().required().min(3);

schema.validate('ab')
  .catch(err => {
    console.log(err.errors);
  });

Здесь цепочка .required().min(3) не изменяет класс, а добавляет тесты в внутренний список Schema.

Основные подходы к расширению Schema

Вместо прямого наследования чаще используются три механизма:

  1. добавление пользовательских методов через addMethod
  2. использование mixed() как базового расширяемого типа
  3. композиция через concat и вложенные схемы

Расширение через addMethod

Функция addMethod позволяет расширять прототип любого типа схемы или базового Schema. Это основной инструмент расширения поведения библиотеки.

Общая структура

yup.addMethod(yup.string, 'startsWithA', function (message) {
  return this.test('starts-with-a', message, function (value) {
    if (!value) return true;
    return value.startsWith('A');
  });
});

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

const schema = yup.string().startsWithA('Строка должна начинаться с A');

schema.validate('Hello'); // ошибка
schema.validate('Apple'); // ok

Механика работы

  • addMethod модифицирует прототип схемы
  • метод получает текущий экземпляр через this
  • возвращается новый экземпляр схемы (immutability сохраняется)
  • тест добавляется в очередь валидации

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


Использование mixed как базового расширяемого типа

mixed() — наиболее универсальная схема. Она используется как фундамент для создания кастомных валидаторов.

const customSchema = yup.mixed().test(
  'is-positive-id',
  'ID должен быть положительным числом',
  (value) => typeof value === 'number' && value > 0
);

Этот механизм удобен для создания полностью новых правил, не зависящих от встроенных типов.

Пример сложной кастомной схемы

const uuidSchema = yup.mixed().test(
  'is-uuid',
  'Неверный UUID',
  (value) => {
    const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    return typeof value === 'string' && regex.test(value);
  }
);

Кастомизация через наследование Schema

Хотя библиотека не ориентирована на классическое ООП-наследование, технически возможно создать собственный класс, унаследованный от Schema.

import { Schema } from 'yup';

class PositiveNumberSchema extends Schema {
  constructor() {
    super({
      type: 'positive-number'
    });
  }

  _typeCheck(value) {
    return typeof value === 'number' && value > 0;
  }
}

Такой подход используется редко, поскольку:

  • требует понимания внутреннего API _typeCheck
  • может ломаться при обновлениях библиотеки
  • хуже интегрируется с цепочками методов

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


Переиспользование через concat

concat позволяет объединять две схемы, сохраняя все правила обеих.

const base = yup.string().min(3);
const extended = yup.string().matches(/^[A-Z]/);

const result = base.concat(extended);

В результате:

  • применяются все тесты из base
  • добавляются проверки из extended
  • порядок валидации сохраняется

Этот механизм часто используется как безопасная альтернатива наследованию.


Композиция вместо наследования

Архитектура Yup делает акцент на композиции схем, а не на расширении классов.

const passwordSchema = yup.string()
  .min(8)
  .matches(/[A-Z]/)
  .matches(/[0-9]/)
  .required();

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


Расширение через кастомные тесты

Метод test является универсальным инструментом расширения логики:

const schema = yup.number().test(
  'is-even',
  'Число должно быть чётным',
  (value) => value % 2 === 0
);

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

yup.object({
  min: yup.number(),
  max: yup.number().test(
    'greater-than-min',
    'max должен быть больше min',
    function (value) {
      return value > this.parent.min;
    }
  )
});

Интеграция с TypeScript и расширение типов

В TypeScript-окружении расширение Schema требует синхронизации типов:

declare module 'yup' {
  interface StringSchema {
    startsWithA(message?: string): StringSchema;
  }
}

И реализация:

yup.addMethod(yup.string, 'startsWithA', function (message) {
  return this.test('starts-with-a', message, value =>
    !value || value.startsWith('A')
  );
});

Это позволяет сохранять типобезопасность при расширении API.


Паттерны построения расширений

На практике используются несколько устойчивых подходов:

1. Библиотека расширений

  • набор addMethod-расширений
  • подключается один раз в проекте

2. Фабрики схем

const createPositiveNumber = () =>
  yup.number().test(
    'positive',
    'must be positive',
    v => v > 0
  );

3. Слоистые схемы

  • базовая схема
  • слой бизнес-правил
  • слой UI-валидации

Ограничения модели расширения Schema

Несмотря на гибкость, существуют архитектурные ограничения:

  • невозможность глубокого наследования без риска несовместимости
  • отсутствие официального plugin API уровня классов
  • зависимость расширений от внутреннего состояния tests
  • сложность отладки цепочек при большом количестве кастомных методов

Эти ограничения объясняют, почему библиотека делает упор на композицию и функциональное расширение, а не на классическое ООП.


Внутренний механизм регистрации расширений

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

  • имя теста
  • сообщение об ошибке
  • функция проверки
  • контекст выполнения
  • флаги (например, exclusive)

При вызове validate происходит последовательный проход по цепочке:

  1. трансформации
  2. базовые проверки типа
  3. кастомные тесты
  4. асинхронные проверки (если есть)

Именно эта структура делает расширение предсказуемым и повторяемым независимо от сложности схемы.