Zod .meta() — одна схема для валидации, UI и доступности
В классическом подходе одно поле описано в 2-3 местах. Zod v4 .meta() позволяет хранить label, placeholder и helperText прямо в схеме. Результат: 80 строк JSX → 8.
10 мая 2026 г.
--TL;DR:
- В классическом подходе одно поле формы описано в 2-3 местах: Zod-схема, JSX-пропсы, HTML-атрибуты. Рассинхронизация — вопрос времени.Zod v4
Кому полезно:
- Junior: поймёте, зачем нужен DRY в формах и как работает Zod
Вторая статья из цикла «@letar/forms — от боли к декларативным формам». Как мы объединили правила валидации и UI-метаданные в одной Zod-схеме и почему это убивает дублирование.
<details><summary>Что такое Zod v4 и чем отличается от v3</summary>
Zod — библиотека валидации для TypeScript. Вы описываете схему данных — Zod проверяет входные данные и выводит TypeScript-типы автоматически.
Zod v4 (выпущен в 2025) добавил несколько ключевых фич:
Миграция:
</details>
Проблема: три источника правды
В прошлой статье мы показали классическую боль React-форм. Давайте присмотримся к одному конкретному полю — email:
// 1. Zod-схема (валидация)
const schema = z.object({
email: z.string().email('Некорректный email').max(255),
})
// 2. JSX (UI-метаданные)
<FormControl>
<FormLabel>Email</FormLabel>
<Input
placeholder="user@example.com"
type="email"
maxLength={255}
/>
<FormHelperText>Используется для входа</FormHelperText>
<FormErrorMessage>{error}</FormErrorMessage>
</FormControl>
Видите проблему? Одно поле — и уже два файла знают про него разное:
| Что | Где живёт | Кто обновляет |
|---|---|---|
| «Максимум 255 символов» | Zod: | Бэкенд-разработчик |
| JSX-пропс | Фронтенд-разработчик |
| «Email» | | Дизайнер/фронтенд |
| HTML-атрибут | Фронтенд |
| «Некорректный email» | Zod-сообщение | Бэкенд |
| «user@example.com» | placeholder | Дизайнер |
| «Используется для входа» | helperText | Продакт/дизайнер |
Семь фактов о поле, разбросанных по двум местам (а часто и по трём — если есть серверная валидация). Рассинхронизация — вопрос времени.
Решение: Zod
как единый источник
Zod v4 добавил метод
import { z } from 'zod/v4'
const UserSchema = z.object({
email: z
.string()
.email('Некорректный email')
.max(255)
.meta({
ui: {
title: 'Email',
placeholder: 'user@example.com',
description: 'Используется для входа',
},
}),
name: z
.string()
.min(2, 'Минимум 2 символа')
.max(100)
.meta({
ui: {
title: 'Имя',
placeholder: 'Иван Иванов',
},
}),
age: z
.number()
.min(18, 'Минимум 18 лет')
.max(150)
.meta({
ui: {
title: 'Возраст',
description: 'Полных лет',
},
}),
})
Теперь всё в одном месте. Одна схема определяет:
- Тип данных (
А JSX остаётся чистым:
<Form schema={UserSchema} initialValue={data} onSubmit={save}>
<Form.Field.String name="email" />
<Form.Field.String name="name" />
<Form.Field.Number name="age" />
<Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>
Пять строк JSX. Ноль дублирования.
Как это работает под капотом
Извлечение метаданных
Когда
- Получает Zod-схему из контекста формыНаходит поле по пути (
// Упрощённая версия нашего schema-meta.ts
function getFieldMeta(schema, path) {
// Навигация по вложенным путям: "user.address.city"
const parts = path.split('.')
let current = schema
for (const part of parts) {
// Раскрываем обёртки: optional, nullable, default, effects, pipeline
current = unwrapToBaseSchema(current)
// Навигация в shape объекта
if (current._zod?.def?.type === 'object') {
current = current._zod.def.shape[part]
} // Или в element массива
else if (current._zod?.def?.type === 'array') {
current = current._zod.def.element
}
}
// Извлекаем метаданные
const meta = typeof current?.meta === 'function' ? current.meta() : undefined
return meta?.ui
}
Ключевой момент — unwrapping. В Zod v4 одно поле может быть обёрнуто в несколько слоёв:
z.string().email().optional().default('').pipe(z.string().trim())
Это
Автоматические constraints
Помимо
function extractConstraints(schema) {
const checks = schema._zod?.def?.checks ?? []
const constraints = {}
for (const check of checks) {
switch (check.kind) {
case 'min':
constraints.minLength = check.value
break
case 'max':
constraints.maxLength = check.value
break
case 'email':
constraints.inputType = 'email'
break
case 'url':
constraints.inputType = 'url'
break
case 'regex':
constraints.pattern = check.value.source
break
}
}
return constraints
}
Это значит:
z.string().min(2).max(100)
// → автоматически: minLength={2} maxLength={100}
z.string().email()
// → автоматически: type="email"
z.string().url()
// → автоматически: type="url"
z.number().min(0).max(10)
// → автоматически: min={0} max={10}
Вам не нужно прописывать HTML-атрибуты вручную. DRY в чистом виде.
Приоритет: props > schema
Иногда нужно переопределить то, что в схеме. Приоритет всегда в пользу явного пропса:
const Schema = z.object({
title: z.string().meta({
ui: { title: 'Название', placeholder: 'Введите...' },
}),
})
// Используем метаданные из схемы
<Form.Field.String name="title" />
// → label="Название", placeholder="Введите..."
// Переопределяем
<Form.Field.String name="title" label="Заголовок статьи" placeholder="О чём пишем?" />
// → label="Заголовок статьи", placeholder="О чём пишем?"
Это важно для кейсов, когда одна схема используется в разных контекстах (создание vs редактирование).
Продвинутые метаданные
const ProductSchema = z.object({
name: z.string().meta({
ui: {
title: 'Название продукта',
placeholder: 'Введите название',
description: 'Будет отображаться в каталоге',
},
}),
price: z
.number()
.min(0)
.meta({
ui: {
title: 'Цена',
fieldType: 'currency', // Какой компонент использовать
fieldProps: { currency: 'RUB' }, // Пропсы компонента
},
}),
category: z.enum(['clothes', 'shoes', 'accessories']).meta({
ui: {
title: 'Категория',
fieldType: 'radioCard', // Карточки вместо dropdown
fieldProps: {
options: [
{ value: 'clothes', label: 'Одежда', icon: '👕' },
{ value: 'shoes', label: 'Обувь', icon: '👟' },
{ value: 'accessories', label: 'Аксессуары', icon: '💍' },
],
},
},
}),
})
Автоматический HTML autocomplete
Есть ещё один тип метаданных, который большинство библиотек игнорирует — атрибут
| Имя поля | autocomplete | Что подставит браузер |
|---|---|---|
| | Email из профиля |
| | Имя |
| | Фамилия |
| | Телефон |
| | Пароль из менеджера |
| | Адрес |
| | Город |
| | Почтовый индекс |
| | Номер карты |
| | Срок действия |
Внутри библиотеки — 40+ таких маппингов. Достаточно назвать поле
Если автоматика не подходит, переопределение через
z.string().meta({ autocomplete: 'shipping street-address' })
Приоритет: явный проп на поле →
Вложенные объекты и массивы
Метаданные работают на любой глубине вложенности:
const OrderSchema = z.object({
customer: z.object({
name: z.string().meta({ ui: { title: 'Имя клиента' } }),
phone: z.string().meta({
ui: { title: 'Телефон', fieldType: 'phone' },
}),
}),
items: z.array(
z.object({
product: z.string().meta({ ui: { title: 'Товар' } }),
quantity: z.number().min(1).meta({ ui: { title: 'Кол-во' } }),
})
),
})
// JSX: чистая вёрстка
<Form schema={OrderSchema} initialValue={data} onSubmit={save}>
<Form.Group name="customer">
<Form.Field.String name="name" />
<Form.Field.Phone name="phone" />
</Form.Group>
<Form.Group.List name="items">
<Form.Field.String name="product" />
<Form.Field.Number name="quantity" />
<Form.Group.List.Button.Add>+ Добавить товар</Form.Group.List.Button.Add>
</Form.Group.List>
<Form.Button.Submit />
</Form>
Сравнение: до и после
До (React Hook Form + Zod + Chakra UI)
// schema.ts
const schema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
phone: z.string().regex(/^\+7\d{10}$/),
age: z.number().min(18).max(150),
bio: z.string().max(500).optional(),
})
// form.tsx — 80+ строк
<form onSubmit={handleSubmit(onSubmit)}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>Имя</FormLabel>
<Input placeholder="Иван Иванов" maxLength={100} {...register('name')} />
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input placeholder="user@example.com" type="email" {...register('email')} />
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.phone}>
<FormLabel>Телефон</FormLabel>
<Input placeholder="+7 (999) 123-45-67" type="tel" {...register('phone')} />
<FormHelperText>Формат: +7XXXXXXXXXX</FormHelperText>
<FormErrorMessage>{errors.phone?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.age}>
<FormLabel>Возраст</FormLabel>
<NumberInput min={18} max={150}>
<NumberInputField {...register('age', { valueAsNumber: true })} />
</NumberInput>
<FormErrorMessage>{errors.age?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.bio}>
<FormLabel>О себе</FormLabel>
<Textarea placeholder="Расскажите о себе..." maxLength={500} {...register('bio')} />
<FormHelperText>До 500 символов</FormHelperText>
<FormErrorMessage>{errors.bio?.message}</FormErrorMessage>
</FormControl>
<Button type="submit">Сохранить</Button>
</form>
~50 строк JSX, дублирование label/placeholder/maxLength/type.
После (@letar/forms)
const ProfileSchema = z.object({
name: z.string().min(2).max(100).meta({
ui: { title: 'Имя', placeholder: 'Иван Иванов' },
}),
email: z.string().email().meta({
ui: { title: 'Email', placeholder: 'user@example.com' },
}),
phone: z.string().regex(/^\+7\d{10}$/).meta({
ui: { title: 'Телефон', fieldType: 'phone', description: 'Формат: +7XXXXXXXXXX' },
}),
age: z.number().min(18).max(150).meta({
ui: { title: 'Возраст' },
}),
bio: z.string().max(500).optional().meta({
ui: { title: 'О себе', placeholder: 'Расскажите о себе...', description: 'До 500 символов' },
}),
})
<Form schema={ProfileSchema} initialValue={data} onSubmit={save}>
<Form.Field.String name="name" />
<Form.Field.String name="email" />
<Form.Field.Phone name="phone" />
<Form.Field.Number name="age" />
<Form.Field.Textarea name="bio" />
<Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>
8 строк JSX. Ноль дублирования.
Или совсем коротко:
<Form.FromSchema schema={ProfileSchema} initialValue={data} onSubmit={save} />
Одна строка.
Итоги
| Аспект | Классический подход | @letar/forms |
|---|---|---|
| Label | В JSX | В Zod |
| Placeholder | В JSX | В Zod |
| maxLength/min/max | В JSX вручную | Из Zod автоматически |
| type="email" | В JSX вручную | Из |
| Ошибки | В JSX вручную | Автоматически |
| Источников правды | 2-3 | 1 |
Ключевая идея: схема — единственный источник правды. JSX содержит только вёрстку и имена полей.
Попробовать
<details><summary>Установка</summary>
bun add @letar/forms
import { z } from 'zod/v4'
const Schema = z.object({
email: z.email().meta({ ui: { title: 'Email', placeholder: 'user@example.com' } }),
name: z.string().min(2).meta({ ui: { title: 'Имя' } }),
})
<Form schema={Schema} initialValue={{ email: '', name: '' }} onSubmit={save}>
<Form.Field.String name="email" />
<Form.Field.String name="name" />
<Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>
</details>
Навигация по серии← Предыдущая: Формы в React: почему больно→ Следующая: От первой формы до архитектуры: Compound Components
Используете ли вы Zod v4 .meta() в своих проектах? Для валидации, генерации форм или чего-то ещё?