Typing для кастомных методов

Расширение библиотеки Yup собственными методами требует одновременного учета двух уровней: runtime-реализации и статической типизации в TypeScript. Если добавить метод только через addMethod, он будет доступен в рантайме, но TypeScript не будет понимать его существование без дополнительного описания типов. Именно синхронизация этих слоев делает кастомные методы валидации частью полноценной типобезопасной системы.

Yup предоставляет механизм расширения типов через декларативное расширение интерфейсов. Основной принцип заключается в использовании declare module 'yup', где описываются новые методы для конкретных схем.

Пример добавления кастомного метода для строковой схемы:

import * as yup from 'yup';

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

На этом этапе TypeScript уже распознает метод, но его реализация отсутствует. Она добавляется через addMethod.

yup.addMethod(yup.string, 'onlyLowercase', function (message = 'Only lowercase allowed') {
  return this.test('only-lowercase', message, function (value) {
    if (value == null) return true;
    return value === value.toLowerCase();
  });
});

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

Типизация кастомных методов с параметрами

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

declare module 'yup' {
  interface NumberSchema {
    maxWithMessage(max: number, message?: string): this;
  }
}

Реализация:

yup.addMethod(yup.number, 'maxWithMessage', function (max: number, message?: string) {
  return this.max(max, message);
});

Здесь метод оборачивает стандартный max, но добавляет кастомизацию интерфейса. Такой подход часто используется для унификации сообщений об ошибках в больших проектах.

Типизация методов с использованием generics

Более сложные кастомные методы требуют учета дженериков. Это особенно важно при работе с объектными схемами или трансформациями.

Пример метода, который добавляет обязательное поле с дефолтным значением:

declare module 'yup' {
  interface ObjectSchema<TShape> {
    withDefaultField<K extends string, V>(
      key: K,
      value: V
    ): ObjectSchema<TShape & Record<K, V>>;
  }
}

Реализация:

yup.addMethod(yup.object, 'withDefaultField', function (key, value) {
  return this.default({
    ...this.getDefault(),
    [key]: value,
  });
});

Здесь происходит расширение типа TShape, что позволяет TypeScript учитывать добавленное поле в дальнейшем использовании схемы.

Типизация кастомных test-методов

Метод test является базовым механизмом кастомной валидации, но его типизация требует отдельного внимания. Yup предоставляет контекст TestContext, который можно использовать для более точного описания логики.

import { TestContext } from 'yup';

yup.addMethod(yup.string, 'isEqualToField', function (field: string, message?: string) {
  return this.test('is-equal-to-field', message, function (value) {
    const { parent } = this as TestContext & { parent: Record<string, any> };

    return value === parent[field];
  });
});

Типизация parent часто требует явного уточнения, поскольку Yup не всегда может вывести структуру объекта. Это особенно важно в сложных формах с вложенными схемами.

Расширение mixed-схем и универсальные методы

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

declare module 'yup' {
  interface MixedSchema<TType = any> {
    nullableWhenEmpty(): this;
  }
}

Реализация:

yup.addMethod(yup.mixed, 'nullableWhenEmpty', function () {
  return this.transform((value) => {
    if (value === '') return null;
    return value;
  });
});

Тип TType позволяет сохранить совместимость с различными схемами без потери типовой информации.

Типизация через возврат this и сохранение fluent API

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

interface StringSchema {
  trimAndLowercase(): this;
}
yup.addMethod(yup.string, 'trimAndLowercase', function () {
  return this.transform((value) =>
    typeof value === 'string' ? value.trim().toLowerCase() : value
  );
});

Если вернуть не this, а конкретный тип схемы, цепочка методов может стать неконсистентной, особенно при смешанных композициях when, concat, nullable.

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

Иногда один и тот же метод требуется для нескольких типов схем. В этом случае используется расширение нескольких интерфейсов.

declare module 'yup' {
  interface StringSchema {
    normalizeSpaces(): this;
  }

  interface ArraySchema<T> {
    uniqueItems(): this;
  }
}

Реализация для массива:

yup.addMethod(yup.array, 'uniqueItems', function () {
  return this.test('unique-items', function (value) {
    if (!Array.isArray(value)) return true;
    return new Set(value).size === value.length;
  });
});

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

Работа с контекстом и типизация кастомных ошибок

Контекст this внутри test-функций является ключевым элементом сложной валидации. Его типизация часто игнорируется, что приводит к потере безопасности.

import { TestContext } from 'yup';

interface ExtendedContext extends TestContext {
  options?: Record<string, unknown>;
}
this.test('custom-rule', function (value) {
  const ctx = this as ExtendedContext;

  return Boolean(value);
});

Типизация контекста особенно важна при использовании динамических правил через when и зависимые поля.

Объединение кастомных методов с inference типов схем

Yup позволяет извлекать типы через InferType. При добавлении кастомных методов важно не нарушить этот механизм.

const schema = yup
  .string()
  .onlyLowercase()
  .trimAndLowercase();

type Result = yup.InferType<typeof schema>;

Тип Result должен оставаться string, даже если добавлены дополнительные трансформации. Это достигается корректным использованием this и отсутствием изменения базового типа схемы.

Типизация методов с побочными эффектами трансформации

Методы, изменяющие структуру данных, требуют особой осторожности. Например:

declare module 'yup' {
  interface StringSchema {
    parseNumber(): NumberSchema;
  }
}

Здесь происходит смена типа схемы, и TypeScript должен явно это отражать.

yup.addMethod(yup.string, 'parseNumber', function () {
  return this.transform((value) => Number(value)).number();
});

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

Стабильность типизации при масштабировании расширений

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

  • validation methods
  • transformation methods
  • normalization methods

Каждая группа описывается в отдельном augmentation-блоке, что снижает вероятность пересечений и облегчает поддержку.

declare module 'yup' {
  interface StringSchema {
    normalizeEmail(): this;
  }
}
declare module 'yup' {
  interface StringSchema {
    isUUID(): this;
  }
}

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

Контроль типов при композиции схем

Композиция через concat и shape может приводить к сложным типовым конфликтам при кастомных методах. Важно сохранять совместимость типов:

const base = yup.string().onlyLowercase();
const extended = base.concat(yup.string().trim());

Если кастомные методы возвращают некорректные типы, concat может начать терять inference. Поэтому любое расширение должно сохранять базовую совместимость с оригинальным типом схемы.