В клиентских веб-приложениях (Next.js Client Components, SPA, микрофронтенды) размер JavaScript-бандла напрямую определяет ключевые метрики производительности Core Web Vitals: Total Blocking Time (TBT) и Interaction to Next Paint (INP). Чем больше объем загружаемого скрипта, тем дольше процессор мобильного устройства выполняет парсинг и компиляцию в движке V8, блокируя основной поток браузера.
Исторический лидер экосистемы — библиотека Zod — спроектирована по объектно-ориентированной модели с цепочечным синтаксисом (Fluent Interface):
z.string().min(5).max(100).email().trim();При такой архитектуре все возможные методы валидации и трансформации жестко привязаны к прототипу класса ZodString и базового ZodType. Алгоритмы устранения мертвого кода (Tree-Shaking) в современных сборщиках (Webpack, Rollup, Turbopack, Vite) анализируют граф статических импортов на уровне ES-модулей. Поскольку методы класса связаны единым монолитным объектом, сборщик не способен определить, какие методы используются в приложении, а какие нет. В результате импорт даже простейшей схемы z.string() приводит к включению всей библиотеки Zod целиком (около 55–70 КБ несжатого кода / ~14 КБ gzip) в клиентский бандл.
Valibot решает эту проблему радикальным архитектурным переходом к чистой функциональной декомпозиции. В Valibot полностью отсутствуют монолитные классы. Каждый примитив (string, number), каждый валидатор (email, minLength, regex) и каждая трансформация (trim, toLowerCase) представляют собой независимые, экспортируемые функции высшего порядка.
Связующим звеном выступает конвейер pipe(), объединяющий базовую схему типа с цепочкой валидационных и трансформационных действий (Actions). Сборщик включает в финальный JavaScript-бандл исключительно те функции, которые были явно импортированы разработчиком, позволяя снизить размер кода валидации формы до менее чем 1 КБ (gzip) (сокращение веса до 90–95%).
Разница в графе зависимостей между ООП-цепочками и модульными функциями наглядно объясняет механизм работы Tree-Shaking:
+-------------------------------------------------------------------------+
| МОНОЛИТНЫЙ ГРАФ ZOD (Tree-Shaking невозможен) |
+-------------------------------------------------------------------------+
| import { z } from 'zod'; |
| z.string().email(); |
| |
| Сборщик видит класс ZodString: |
| class ZodString { |
| min() { ... } // <- Тянется в бандл, даже если не используется! |
| max() { ... } // <- Тянется в бандл! |
| uuid() { ... } // <- Тянется в бандл! |
| regex() { ... } // <- Тянется в бандл! |
| ip() { ... } // <- Тянется в бандл! |
| } |
| => Итоговый вес бандла: ~14 КБ (gzip) |
+-------------------------------------------------------------------------+
| |
| МОДУЛЬНЫЙ ГРАФ VALIBOT (Полный Tree-Shaking) |
+-------------------------------------------------------------------------+
| import { object, string, email, pipe } from 'valibot'; |
| |
| Сборщик анализирует ES-экспорты: |
| - object() => Включен в бандл (~350 байт) |
| - string() => Включен в бандл (~150 байт) |
| - email() => Включен в бандл (~200 байт) |
| - pipe() => Включен в бандл (~100 байт) |
| * Все остальные 150+ функций библиотеки полностью удалены из сборки! |
| => Итоговый вес бандла: ~850 байт (gzip)! |
+-------------------------------------------------------------------------+Выполняется проверка корневого типа (string, number, boolean). При несовпадении пайплайн аварийно завершается.
Данные передаются в первое действие (например, нормализация trim()), которое возвращает модифицированное значение.
Очищенное значение проходит сквозь серию предикатов (minLength, email). Ошибки фиксируются в массиве issues.
Если массив issues пуст, safeParse возвращает статус успеха с трансформированным значением output.
Почему импорт схемы Zod (z.string().email()) всегда тянет в клиентский бандл всю библиотеку целиком (~14 КБ gzip), тогда как Valibot (pipe(string(), email())) занимает менее 1 КБ?
Первый аргумент pipe() проверяет базовый тип (string, number, object). Если тип не совпадает, пайплайн прерывается.
Данные передаются по цепочке через функции трансформации (trim, toLowerCase) и валидаторы (minLength, email).
При обнаружении несоответствий действие генерирует объект Issue со строгим указанием пути (path) и сообщения.
Метод safeParse возвращает типизированный объект { success: true, output } либо { success: false, issues }.
Рассмотрим практическую реализацию валидации профиля пользователя:
import * as v from 'valibot';
// Описание схемы через функциональный конвейер pipe()
export const UserProfileSchema = v.object({
id: v.pipe(
v.string(),
v.uuid('Идентификатор должен быть валидным UUID v4')
),
username: v.pipe(
v.string(),
v.trim(), // Трансформация: автоматическое удаление пробелов
v.minLength(3, 'Минимальная длина имени — 3 символа'),
v.maxLength(20, 'Максимальная длина имени — 20 символов')
),
email: v.pipe(
v.string(),
v.trim(),
v.toLowerCase(), // Трансформация: нормализация регистра
v.email('Некорректный синтаксис email-адреса')
),
age: v.pipe(
v.number(),
v.minValue(18, 'Сервис доступен только лицам старше 18 лет'),
v.maxValue(100, 'Укажите корректный возраст')
),
website: v.optional(
v.pipe(
v.string(),
v.url('Некорректный формат URL адреса')
)
),
});
// Статический инференс типа входных данных (до трансформаций)
export type UserProfileInput = v.InferInput<typeof UserProfileSchema>;
// Статический инференс типа выходных данных (после trim и toLowerCase)
export type UserProfileOutput = v.InferOutput<typeof UserProfileSchema>;[!NOTE] Обратите внимание на разделение
InferInputиInferOutput. Поскольку пайплайн содержит трансформации (trim(),toLowerCase()), входящие данные могут отличаться от исходящих по своей форме или типу, что строго отслеживается системой типов TypeScript.
Valibot позволяет создавать собственные валидаторы без раздувания кодовой базы с помощью функций check или низкоуровневых интерфейсов rawCheck:
import * as v from 'valibot';
// Кастомное действие проверки надежности пароля
export function passwordStrength(requirementMessage = 'Пароль должен содержать минимум 1 цифру и 1 спецсимвол') {
return v.check((input: string) => {
const hasNumber = /\d/.test(input);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(input);
return hasNumber && hasSpecialChar;
}, requirementMessage);
}
// Кастомное действие валидации ИНН (10 цифр с контрольной суммой)
export function isValidInn(errorMessage = 'Некорректный формат ИНН организации') {
return v.check((input: string) => {
if (!/^\d{10}$/.test(input)) return false;
const coefficients = [2, 4, 10, 3, 5, 9, 4, 6, 8];
const controlSum = coefficients.reduce(
(sum, coeff, idx) => sum + coeff * Number(input[idx]),
0
);
const controlDigit = (controlSum % 11) % 10;
return Number(input[9]) === controlDigit;
}, errorMessage);
}
// Использование кастомных действий в конвейере
export const SecurityFormSchema = v.object({
inn: v.pipe(
v.string(),
v.trim(),
isValidInn()
),
password: v.pipe(
v.string(),
v.minLength(8, 'Длина пароля — не менее 8 символов'),
passwordStrength()
),
});В клиентских формах следует всегда использовать safeParse во избежание генерации необработанных исключений (Uncaught Exceptions):
import * as v from 'valibot';
import { UserProfileSchema, UserProfileOutput } from './profile-schema';
export interface FormSubmissionResult {
isSuccess: boolean;
data?: UserProfileOutput;
fieldErrors?: Record<string, string>;
}
export function handleProfileSubmission(rawFormData: unknown): FormSubmissionResult {
// safeParse не выбрасывает исключений и возвращает типизированный результат
const result = v.safeParse(UserProfileSchema, rawFormData);
if (!result.success) {
// Преобразование плоского массива issues в мапу ошибок для полей формы
const fieldErrors: Record<string, string> = {};
for (const issue of result.issues) {
// Извлечение имени первого сегмента пути ошибки (например, 'email' или 'username')
const fieldName = issue.path?.[0]?.key;
if (typeof fieldName === 'string' && !fieldErrors[fieldName]) {
fieldErrors[fieldName] = issue.message;
}
}
return {
isSuccess: false,
fieldErrors,
};
}
// Данные гарантированно валидны и трансформированы
return {
isSuccess: true,
data: result.output,
};
}Создадим готовый архитектурный модуль с доменными валидаторами для клиентских форм аутентификации:
import * as v from 'valibot';
// 1. Базовые переиспользуемые строительные блоки
export const EmailSchema = v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.email('Некорректный синтаксис корпоративного email'),
v.maxLength(100, 'Email не должен превышать 100 символов')
);
export const PasswordSchema = v.pipe(
v.string(),
v.minLength(8, 'Пароль должен быть длиной не менее 8 символов'),
v.maxLength(64, 'Пароль не должен превышать 64 символа')
);
// 2. Схема входа
export const LoginSchema = v.object({
email: EmailSchema,
password: PasswordSchema,
rememberMe: v.optional(v.boolean(), false),
});
// 3. Схема регистрации с валидацией совпадения паролей через forward/check
export const RegistrationSchema = v.pipe(
v.object({
email: EmailSchema,
password: PasswordSchema,
confirmPassword: v.string(),
agreeToTerms: v.pipe(
v.boolean(),
v.check((val) => val === true, 'Необходимо подтвердить согласие с условиями оферты')
),
}),
// Проверка совпадения двух паролей на уровне всего объекта
v.forward(
v.check(
(input) => input.password === input.confirmPassword,
'Введенные пароли не совпадают'
),
['confirmPassword'] // Ошибка привязывается к полю confirmPassword
)
);
export type LoginCredentials = v.InferOutput<typeof LoginSchema>;
export type RegistrationPayload = v.InferOutput<typeof RegistrationSchema>;Что произойдет, если в схеме Valibot объявить pipe(string(), email(), trim()) и передать строку с пробелами ' [email protected] '?
Инженеры, переходящие с Zod, по инерции пишут v.string().min(5) или v.object().required(). Это приводит к синтаксической ошибке TypeError: v.string(...).min is not a function, так как v.string() возвращает простую структуру дескриптора схемы без методов. Любые валидации обязаны оборачиваться в функцию v.pipe().
// ❌ СЛОМАНО: попытка вызова метода класса
const BrokenSchema = v.string().minLength(5); // TypeError!
// ✅ ПРАВИЛЬНО: функциональная композиция через pipe
const CorrectSchema = v.pipe(v.string(), v.minLength(5));Функция pipe() выполняет действия строго слева направо. Если разместить валидатор email() до действия нормализации trim(), строка " [email protected] " будет отклонена валидатором из-за ведущих пробелов до того, как trim() успеет их удалить. Трансформации данных обязаны предшествовать валидаторам формата.
// ❌ ПЛОХО: email() упадет из-за пробелов до вызова trim()
const BadOrder = v.pipe(v.string(), v.email(), v.trim());
// ✅ ПРАВИЛЬНО: сначала очистка данных, затем валидация
const GoodOrder = v.pipe(v.string(), v.trim(), v.email());Метод v.parse() при любой ошибке валидации выбрасывает тяжелое исключение ValiError. Использование его в обработчиках форм без блока try/catch приводит к краху всего приложения и демонстрации белого экрана ошибки вместо вывода сообщений под инпутами. В клиентском коде форм всегда используйте v.safeParse().
Импорт import * as v from 'valibot' не вредит Tree-Shaking, если сборщик настроен корректно, однако импорт неиспользуемых вспомогательных утилит в серверные файлы может случайно утянуть сторонние полифилы. Старайтесь использовать именованный импорт конкретных функций: import { object, string, pipe } from 'valibot'.