Расширение библиотеки 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, но добавляет
кастомизацию интерфейса. Такой подход часто используется для унификации
сообщений об ошибках в больших проектах.
Более сложные кастомные методы требуют учета дженериков. Это особенно важно при работе с объектными схемами или трансформациями.
Пример метода, который добавляет обязательное поле с дефолтным значением:
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 является базовым механизмом кастомной
валидации, но его типизация требует отдельного внимания. 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 является наиболее гибким типом схемы в 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 и зависимые поля.
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, если злоупотреблять сменой типов.
При росте количества кастомных методов ключевой проблемой становится конфликт деклараций. Чтобы избежать этого, методы группируются по доменам:
Каждая группа описывается в отдельном 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. Поэтому любое
расширение должно сохранять базовую совместимость с оригинальным типом
схемы.