Статья о том, как попытка разобраться в валидации объектов привела к созданию библиотеки валидации схем с runtime-интроспекцией, а на её основе — отдельных библиотек для type-safe маппинга объектов и генерации форм.
Предыстория: большие объекты без TypeScript
Несколько лет назад в одном из моих проектов на чистом JavaScript возникла задача: валидировать большие вложенные объекты со сложной структурой. Объекты содержали различные подобъекты, к каждому из которых применялись свои правила валидации в зависимости от типа.
Задача усложнялась двумя дополнительными требованиями:
Вывод типов. Проект был без TypeScript, поэтому единственным способом добавить типизацию были JSDoc-комментарии. Мне нужно было, чтобы из определения схемы автоматически выводился тип объекта — и чтобы этот тип подхватывался IDE.
Сохранение JSDoc-комментариев. Когда из схемы выводится тип, важно, чтобы описания полей (те самые JSDoc-комментарии) сохранялись и были видны в тултипах IDE. Это было критично, где документация в коде — неплохой способ передачи и сохранения знаний.
Времени было достаточно и я начал строить что-то своё. Fluent API, цепочки вызовов, валидаторы, вывод типов через дженерики — всё как положено.
«Подождите, это же Zod»
Когда я был уже глубоко в разработке, я случайно наткнулся на Zod. Я помню момент, когда увидел его API и подумал: «Это буквально то, что я сейчас пишу». Fluent builder, вывод типов из схемы, валидация — всё один в один.
Но я решил не бросать свой проект. К тому моменту у меня уже было глубокое понимание внутренностей, и я видел конкретные вещи, которые хотел сделать иначе. Плюс, моя реализация уже работала в нескольких внутренних проектах, и я мог контролировать каждый аспект поведения — от того, как работает валидация, до того, как извлекаются ошибки.
Проблема строковых констант
Одна из вещей, которая меня всегда раздражала в различных библиотеках — это извлечение ошибок по строковым путям:
// Типичный подход — строковые константы const errors = result.getErrors('user.address.city');
Что здесь не так? Если я переименую address в residenceAddress, код продолжит компилироваться, но ошибки перестанут извлекаться. Строковые пути — это пути к runtime-багам. Конечно вы скажете что реальный тип параметра getErrors можно сделать чем-то вроде 'user' | 'user.address' | 'user.address.city', это так и вы будете правы, но мне в целом не нравится идея того что мы работаем со строками.
Мне хотелось чего-то похожего на expression trees из C#. У меня большой бэкграунд в C#, и там давно есть паттерн, когда вместо строки вы передаёте лямбду, а компилятор проверяет, что путь существует:
// Что я хотел — type-safe селекторы const errors = result.getErrorsFor(t => t.user.address.city);
Здесь t — это типизированное дерево свойств схемы. Если я переименую address, TypeScript выдаст ошибку компиляции. А IDE предоставит автодополнение вплоть до вложенных полей.
Именно из этого требования выросла ключевая архитектурная идея библиотеки — PropertyDescriptors.
PropertyDescriptors: схема как дерево дескрипторов свойств
Каждая схема в @cleverbrush/schema эмитирует не просто функцию-валидатор, а структурированное дерево дескрипторов свойств. Каждый узел этого дерева знает:
Какому свойству он соответствует
Какая схема к нему привязана
Как получить значение из объекта по этому пути
Как установить значение
Кто его родительский узел
Это позволяет писать type-safe селекторы, которые работают на произвольной глубине вложенности:
import { object, string, number, InferType } from '@cleverbrush/schema'; const OrderSchema = object({ id: string(), customer: object({ name: string().minLength(2), email: string().email(), address: object({ city: string().required('Город обязателен'), zip: number() }) }), total: number().min(0) }); // InferType выводит тип автоматически type Order = InferType<typeof OrderSchema>; // т.к. в моём случае это был JS, то я делал так // /** @type {InferType<typeof OrderSchema>} */ // const order = .... const result = OrderSchema.validate({ id: '123', customer: { name: 'A', // слишком короткое email: 'not-an-email', address: { // city отсутствует zip: -1 } }, total: -5 }); if (!result.valid) { // Type-safe селекторы — IDE подсказывает все поля const nameErrors = result.getErrorsFor(t => t.customer.name); // { isValid: false, errors: ['минимум 2 символа'], seenValue: 'A' } const cityErrors = result.getErrorsFor(t => t.customer.address.city); // { isValid: false, errors: ['Город обязателен'], seenValue: undefined } // Переименуете address → residenceAddress? // TypeScript немедленно покажет ошибку здесь ↑ }
Иммутабельность и fluent API
Каждый вызов метода на схеме возвращает новый экземпляр билдера. Оригинальная схема никогда не мутируется:
const base = string().minLength(2); const withMax = base.maxLength(100); // новый экземпляр const optional = base.optional(); // ещё один новый экземпляр // base, withMax и optional — три независимых схемы
Это позволяет безопасно композировать схемы, переиспользовать их как «базовые блоки» и передавать между модулями без страха побочных эффектов.
Какие билдеры есть
Библиотека предоставляет 14 типов билдеров:
Билдер | Описание | Пример |
|---|---|---|
| Строки с |
|
| Числа с |
|
| Булевы значения |
|
| Даты с |
|
| Объекты с именованными свойствами |
|
| Массивы с типизированными элементами |
|
| Объединения типов, дискриминируемые |
|
| Кортежи фиксированной длины |
|
| Записи с динамическими ключами |
|
| Функции с типизацией |
|
| Произвольные значения |
|
|
|
|
| Ленивые (рекурсивные) схемы |
|
| Обёртка чужих Standard Schema v1 схем |
|
Каждый билдер наследуется от базового SchemaBuilder, который предоставляет общие методы:
schema .optional() // принимает undefined .required('обязательное поле') // не принимает undefined .nullable() // принимает null .default('fallback') // значение по умолчанию при undefined .catch('safe') // значение при любой ошибке валидации .readonly() // на уровне типов: Readonly<T> .brand<'UserId'>() // branded type .describe('Описание поля') // текстовое описание (сохраняется в схеме и может быть извлечено через интроспекцию) .addValidator(fn) // пользовательский валидатор .addPreprocessor(fn) // трансформация перед валидацией .introspect() // получить метаданные схемы
Runtime-интроспекция: .introspect()
Каждая схема в @cleverbrush/schema — это не чёрный ящик. Метод .introspect() возвращает полное описание схемы в виде обычного объекта: тип, ограничения, флаги, описания, метаданные расширений. Всё, что вы задали через fluent API, можно прочитать обратно в рантайме:
import { string, number, object } from '@cleverbrush/schema'; const Name = string() .minLength(2) .maxLength(100) .describe('Имя пользователя') .optional(); const info = Name.introspect(); info.type; // 'string' info.isRequired; // false (потому что .optional()) info.isNullable; // false info.minLength; // 2 info.maxLength; // 100 info.description; // 'Имя пользователя' info.extensions; // {} const Age = number().min(18).default(25); const ageInfo = Age.introspect(); ageInfo.type; // 'number' ageInfo.hasDefault; // true ageInfo.defaultValue; // 25
Каждый тип билдера расширяет базовый introspect() своими полями: StringSchemaBuilder добавляет minLength, maxLength, NumberSchemaBuilder — min, max и т.д.
Зачем это нужно? Именно на introspect() опирается @cleverbrush/schema-json для двунаправленной конвертации в JSON Schema, @cleverbrush/react-form для выбора рендерера по типу поля, и любой пользовательский инструмент, которому нужно «заглянуть внутрь» схемы.
Система расширений
Вместо того чтобы делать один гигантский билдер со всеми возможными валидаторами, ядро библиотеки предоставляет минимальный набор методов. Всё остальное — расширения.
Расширения — это не «чёрные ящики» вроде .refine() в Zod. Они типизированы, интроспектируемы и компонуемы:
import { defineExtension, withExtensions, StringSchemaBuilder, NumberSchemaBuilder } from '@cleverbrush/schema'; // Расширение для строк — HEX-цвет const hexColorExt = defineExtension({ string: { hexColor(this: StringSchemaBuilder) { return this.addValidator((v) => { const valid = /^#[0-9a-f]{6}$/i.test(v as string); return { valid, errors: valid ? [] : [{ message: 'Должен быть HEX-цвет' }] }; }); } } }); // Расширение для чисел — порт const portExt = defineExtension({ number: { port(this: NumberSchemaBuilder) { return this.isInteger().min(1).max(65535); } } }); // Применяем оба расширения сразу — получаем расширенные фабрики const s = withExtensions(hexColorExt, portExt); // .hexColor() доступен на строках, .port() — на числах const colorSchema = s.string().hexColor(); const portSchema = s.number().port(); // Расширения видны через интроспекцию colorSchema.introspect().extensions; // { hexColor: true } portSchema.introspect().extensions; // { port: true } // Можно использовать в объектных схемах const ServerConfig = s.object({ themeColor: s.string().hexColor(), port: s.number().port(), name: s.string().minLength(1) });
Встроенные расширения (email, url, uuid, positive, nonempty, oneOf и другие) реализованы тем же механизмом — они не входят в ядро библиотеки.
Standard Schema v1: совместимость с экосистемой
Библиотека реализует Standard Schema v1. Это значит, что @cleverbrush/schema работает из коробки с tRPC, TanStack Form, React Hook Form, T3 Env, Hono и 50+ другими инструментами — везде, где принимается Standard Schema:
const UserSchema = object({ name: string().minLength(2), email: string().email() }); // Передаём напрямую в tRPC, TanStack Form и т.д. const standardSchema = UserSchema['~standard'];
А extern() работает в обратную сторону — оборачивает чужие Standard Schema библиотеки (Zod, Valibot, ArkType) в билдер @cleverbrush/schema:
import { z } from 'zod'; import { extern, object, string } from '@cleverbrush/schema'; // Оборачиваем Zod-схему const zodAddress = z.object({ city: z.string(), zip: z.number() }); // Используем вместе с нативными схемами const UserSchema = object({ name: string(), address: extern(zodAddress) }); // getErrorsFor работает даже для свойств Zod-схемы const result = UserSchema.validate({ name: 'Alice', address: {} }); const cityErrors = result.getErrorsFor(t => t.address.city);
Применение PropertyDescriptors: маппер
PropertyDescriptors — это не абстрактная фича. Они лежат в основе конкретных инструментов. Первый из них — @cleverbrush/mapper.
В .NET есть AutoMapper — библиотека, которая маппит объекты одного типа в другой, автоматически сопоставляя свойства по имени. Мне хотелось чего-то подобного, но для схем: чтобы маппинг был type-safe, чтобы компилятор проверял полноту, и чтобы селекторы свойств были теми же PropertyDescriptors. Так появился @cleverbrush/mapper.
С маппером определение маппинга type-safe:
import { object, string, number } from '@cleverbrush/schema'; import { mapper } from '@cleverbrush/mapper'; // --- Схемы адресов --- const ApiAddress = object({ city: string(), house_nr: number() }); const DomainAddress = object({ city: string(), houseNr: number() }); // --- Схемы пользователей --- const ApiUser = object({ id: string(), first_name: string(), last_name: string(), birth_year: number(), address: ApiAddress }); const DomainUser = object({ id: string(), fullName: string(), age: number(), address: DomainAddress }); const registry = mapper() // Сначала регистрируем маппинг адресов .configure(ApiAddress, DomainAddress, m => m // city → city — автомаппинг (одинаковое имя и тип) .for(t => t.houseNr).from(f => f.house_nr) ) // Теперь маппинг пользователей .configure(ApiUser, DomainUser, m => m // id → id — автомаппинг (одинаковое имя и тип) // address → address — автомаппинг (маппинг уже зарегистрирован выше!) .for(t => t.fullName) .compute(src => `${src.first_name} ${src.last_name}`) .for(t => t.age) .compute(src => new Date().getFullYear() - src.birth_year) ); const mapFn = registry.getMapper(ApiUser, DomainUser); const user = await mapFn({ id: 'u-42', first_name: 'Иван', last_name: 'Петров', birth_year: 1990, address: { city: 'Москва', house_nr: 15 } }); // { id: 'u-42', fullName: 'Иван Петров', age: 36, address: { city: 'Москва', houseNr: 15 } }
Что здесь важно:
Автомаппинг: свойства с одинаковым именем и совместимым типом маппятся автоматически — вы конфигурируете только то, что отличается.
Compile-time полнота: если вы забыли замапить свойство целевой схемы, TypeScript выдаст ошибку на этапе компиляции.
Type-safe селекторы: и
.for(), и.from()— это те же PropertyDescriptor-селекторы. Переименовали поле? TypeScript покажет.Несовместимые типы: если вы пытаетесь
.from()число в строку, компилятор ругнётся и вы поймёте что надо сделать через.compute().
Применение PropertyDescriptors: React-формы
Второй инструмент — @cleverbrush/react-form. Headless библиотека для работы с формами в React.
Проблема всех популярных React-библиотек для форм (которые я видел) — React Hook Form, Formik, React Final Form — в том, что поля привязываются по строковым именам: register("email"), <Field name="address.city" />. Переименовали свойство — компилятор молчит, форма ломается в рантайме.
@cleverbrush/react-form привязывает поля через те же PropertyDescriptor-селекторы: (t) => t.address.city. Переименовали — ошибка компиляции. Опечатались — ошибка компиляции. Плюс, схема одновременно является и типом, и валидацией, и конфигурацией полей формы.
Отдельно — система рендереров. Вы определяете рендереры для каждого типа схемы один раз на уровне приложения через провайдер. Компонент Field сам находит нужный рендерер по типу схемы поля:
import { object, string, number } from '@cleverbrush/schema'; import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form'; // Определяем рендереры один раз const renderers = { string: ({ value, onChange, onBlur, error, touched, label }) => ( <div> <label>{label}</label> <input value={value ?? ''} onChange={e => onChange(e.target.value)} onBlur={onBlur} /> {touched && error && <span className="error">{error}</span>} </div> ), number: ({ value, onChange, onBlur, error, touched, label }) => ( <div> <label>{label}</label> <input type="number" value={value ?? ''} onChange={e => onChange(Number(e.target.value))} onBlur={onBlur} /> {touched && error && <span className="error">{error}</span>} </div> ) }; // Схема — единственный источник истины const ContactSchema = object({ name: string().required('Имя обязательно').minLength(2), email: string().required('Email обязателен').email(), age: number().min(18, 'Минимум 18 лет') }); function ContactForm() { const form = useSchemaForm(ContactSchema); const handleSubmit = async () => { const result = await form.submit(); if (result.valid) { console.log('Данные:', result.object); } }; return ( <FormSystemProvider renderers={renderers}> {/* Type-safe привязка — IDE подсказывает поля */} <Field forProperty={t => t.name} form={form} label="Имя" /> <Field forProperty={t => t.email} form={form} label="Email" /> <Field forProperty={t => t.age} form={form} label="Возраст" /> <button onClick={handleSubmit}>Отправить</button> </FormSystemProvider> ); }
Библиотека headless — она не навязывает UI. Хотите Material UI? Замените рендереры. Хотите Ant Design? Тоже. Можно вложить FormSystemProvider друг в друга, переопределяя рендереры для отдельных секций.
Для тонкого контроля есть useField:
function CustomNameField() { const form = useSchemaForm(ContactSchema); const field = form.useField(t => t.name); return ( <div> <input value={field.value ?? ''} onChange={e => field.onChange(e.target.value)} onBlur={field.onBlur} /> <p>Dirty: {String(field.dirty)}</p> <p>Touched: {String(field.touched)}</p> {field.error && <p className="error">{field.error}</p>} </div> ); }
Типы и тесты типов
Отдельная история — это типизация. Одни из самых сложных и интересных задач, которые мне приходилось решать в TypeScript, были связаны с mapped types, conditional types и выводом типов из вложенных схем.
Несколько примеров:
InferType<typeof schema>выводит полный TypeScript-тип из определения схемы, включаяoptional,nullable,readonlyи брендированные типы.PropertyDescriptor tree — рекурсивный дженерик, который строит типизированное дерево свойств произвольной глубины.
Маппер использует conditional types, чтобы на этапе компиляции проверить, что все свойства целевой схемы замаплены.
Для тестирования типов я использую expectTypeOf из Vitest — это позволяет писать утверждения на уровне типов, которые ломаются при изменении сигнатур:
import { expectTypeOf } from 'vitest'; const schema = object({ name: string(), age: number().optional() }); type T = InferType<typeof schema>; expectTypeOf<T>().toEqualTypeOf<{ name: string; age?: number; }>();
Естественно весь основной код покрыт тестами логики, а тесты типов обеспечивают сохранение контрактов типов при рефакторинге. Тестовое покрытие по всему монорепозиторию — 97.9%.
Производительность
Раз уж мы сравниваемся с Zod — вот результаты бенчмарков (Vitest bench, Zod v4, одна машина):
Бенчмарк | @cleverbrush/schema | Zod v4 | Разница |
|---|---|---|---|
Массив 100 объектов (valid) | 35,228 ops/s | 13,277 ops/s | 2.65× быстрее |
Массив 100 объектов (invalid) | 899,329 ops/s | 4,396 ops/s | 204× быстрее |
Сложный объект - несколько уровней (valid) | 198,988 ops/s | 136,090 ops/s | 1.46× быстрее |
Вложенный объект (valid) | 690,556 ops/s | 368,893 ops/s | 1.87× быстрее |
Вложенный объект (invalid) | 2,739,319 ops/s | 87,245 ops/s | 31.4× быстрее |
Union (последняя ветка) | 676,107 ops/s | 732,682 ops/s | Zod ~8% быстрее |
Единственный бенчмарк, где Zod быстрее — union match по последней ветке (~8%). Считаю это паритетом.
Размер бандла
Бандл | Gzipped |
|---|---|
| 14 KB |
| 3.8 KB |
Zod v3 (полный) | 14.4 KB |
Zod v4 (полный) | 41 KB |
В 3 раза меньше Zod v4. Sub-path экспорты (/string, /number, /object и т.д.) позволяют подключать только нужные билдеры.
Ноль runtime-зависимостей
Все пакеты монорепозитория — zero runtime dependencies. Strict TypeScript, Biome для линтинга, Vitest для тестов, tsup для сборки.
Итоги
Я не утверждаю, что это «убийца Zod». Zod — отличная библиотека с огромной экосистемой. Но если вам важны runtime-интроспекция схем, type-safe извлечение ошибок без строковых констант и возможность из одного определения схемы получить валидацию, маппинг и формы — посмотрите на @cleverbrush/schema.
Ссылки
Документация и playground: docs.cleverbrush.com
GitHub: github.com/cleverbrush/framework
npm:
npm install @cleverbrush/schema
Буду рад любой обратной связи — по API, документации, пропущенным фичам. Issues и PR приветствуются.
