Коротко: Руководство по выводу типов в TypeScript с помощью z.infer в Zod. Устраняем дублирование интерфейсов, разбираем разницу с z.input и валидацию в Next.js и формах.
При разработке приложений на TypeScript разработчики часто попадают в ловушку дублирования: сначала объявляют interface или type для структуры данных, а затем пишут отдельную схему валидации для проверки входящего payload в runtime. Со временем контракт меняется, правки вносятся только в одно место, и статическая типизация перестает отражать реальность.
Подход Schema-First и использование инструмента z.infer из библиотеки Zod превращают схему валидации в единый источник правды (Single Source of Truth, SSoT). Это гарантирует, что типы времени компиляции и правила runtime-валидации всегда синхронизированы без ручного дублирования кода.
Проблема «двойного описания»: интерфейсы против runtime-схем
TypeScript обеспечивает безопасность типов исключительно на этапе компиляции. После сборки проекта в JavaScript все интерфейсы, алиасы типов и дженерики стираются (type erasure).
Почему ручная синхронизация ведет к багам
Если описать DTO вручную через интерфейс и параллельно написать функцию валидации, между ними неизбежно возникнет рассинхронизация:
// compile-time контракт
interface CreateUserDTO {
email: string;
age?: number;
}
// runtime валидация
const validateUser = (data: unknown) => {
if (typeof data !== 'object' || data === null) return false;
// Поле age забыли проверить или изменили логику, сделав обязательным
return 'email' in data && typeof (data as Record<string, unknown>).email === 'string';
};
Если бизнес-логика изменится (например, поле age станет обязательным), разработчик может обновить интерфейс CreateUserDTO, но забыть изменить функцию validateUser. TypeScript не выдаст ошибку компиляции, но приложение упадет в рантайме при обработке некорректных данных.
Граница доверия (Trust Boundary)
Любые данные, поступающие извне — тело HTTP-запроса, URL query-параметры, localStorage, ответы сторонних микросервисов, — по умолчанию имеют тип unknown.
Приведение их через ключевое слово as (const body = req.body as CreateUserDTO) отключает проверки компилятора и маскирует потенциальные исключения. Единственный надежный способ безопасно сузить тип unknown до валидного рабочего типа — runtime-парсинг.
Механика z.infer: извлечение статических типов
Zod спроектирован по принципу TypeScript-first. Вместо написания интерфейса вручную объявляется схема данных, а TypeScript автоматически выводит тип из этой схемы.
Базовый синтаксис и оператор typeof
Схема Zod — это JavaScript-объект, существующий в рантайме. Чтобы передать его в систему типов TypeScript, используется служебный оператор typeof:
import { z } from 'zod';
// 1. Runtime-схема (значение)
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
tags: z.array(z.string()).optional(),
});
// 2. Статический тип TypeScript (тип)
export type User = z.infer<typeof UserSchema>;
В результате компилятор автоматически генерирует эквивалентный тип:
type User = {
id: string;
name: string;
email: string;
role: "admin" | "user" | "guest";
tags?: string[] | undefined;
}
Если в UserSchema изменится валидация (например, добавится новое поле isActive: z.boolean()), тип User обновится автоматически во всем проекте.
z.infer против z.input: разница при трансформациях
В Zod тип данных до валидации (input) может отличаться от типа данных после валидации (output).
Метод z.infer<T> по умолчанию извлекает именно выходной тип (он полностью идентичен z.output<T>). Если схема выполняет преобразования (.transform(), .default(), парсинг строк), входные и выходные типы перестают совпадать.
Поведение при трансформации данных
Рассмотрим схему парсинга строкового query-параметра пагинации в число:
import { z } from 'zod';
export const PaginationSchema = z.object({
page: z.string().transform((val) => parseInt(val, 10)),
limit: z.string().default('10').transform((val) => parseInt(val, 10)),
});
// Выходной тип (Output) — то, что возвращает .parse()
export type PaginationOutput = z.infer<typeof PaginationSchema>;
// Эквивалентно: { page: number; limit: number; }
// Входной тип (Input) — то, что принимает .parse()
export type PaginationInput = z.input<typeof PaginationSchema>;
// Эквивалентно: { page: string; limit?: string | undefined; }
Когда использовать z.input
z.infer(он жеz.output): используется для типизации внутренней бизнес-логики, обработчиков сервисов, стейта и компонентов, работающих с уже проверенными и очищенными данными.z.input: используется для типизации сырых входных параметров, DTO на входе в контроллер или пропсов HTML-форм до отправки.
Практические сценарии в React и Next.js
Валидация Server Actions и Route Handlers (Next.js)
В Next.js App Router входящие данные от клиента требуют обязательной проверки на сервере.
// app/actions/create-post.ts
'use server';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(5).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
});
export type CreatePostInput = z.input<typeof CreatePostSchema>;
export async function createPostAction(rawInput: unknown) {
const result = CreatePostSchema.safeParse(rawInput);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// result.data имеет строгий тип z.infer<typeof CreatePostSchema>
const { title, content, published } = result.data;
// Сохранение в базу данных...
return { success: true, postId: 'post_123' };
}
Интеграция с React Hook Form
Связка react-hook-form и @hookform/resolvers/zod позволяет использовать схему как для валидации форм, так и для строгой типизации полей ввода:
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const LoginFormSchema = z.object({
login: z.string().min(3, 'Минимум 3 символа'),
password: z.string().min(8, 'Минимум 8 символов'),
});
type LoginFormData = z.infer<typeof LoginFormSchema>;
export const LoginForm: React.FC = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(LoginFormSchema),
});
const onSubmit = (data: LoginFormData) => {
// data полностью типизирована: { login: string; password: string }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('login')} />
{errors.login && <span>{errors.login.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Войти</button>
</form>
);
};
Парсинг ответов внешних API
Вместо каста через const user = (await res.json()) as User безопаснее использовать runtime-парсинг:
const ExternalUserSchema = z.object({
user_id: z.number(),
user_name: z.string(),
});
export type ExternalUser = z.infer<typeof ExternalUserSchema>;
export async function fetchExternalUser(id: number): Promise<ExternalUser> {
const response = await fetch(`https://api.example.com/users/${id}`);
const rawData: unknown = await response.json();
// safeParse возвращает discriminated union: success | error
const parsed = ExternalUserSchema.safeParse(rawData);
if (!parsed.success) {
throw new Error(`Некорректный контракт API: ${parsed.error.message}`);
}
return parsed.data;
}
Ошибки проектирования схем и производительность
Забытый typeof
Распространенная синтаксическая ошибка при переходе на Zod:
// ОШИБКА: UserSchema передана как значение в позицию типа
type User = z.infer<UserSchema>;
// ПРАВИЛЬНО:
type User = z.infer<typeof UserSchema>;
Избыточная глубина вложенности и нагрузка на tsc
Zod строит сложные условные типы на уровне TypeScript. Если схема содержит десятки уровней вложенности, рекурсивные типы (z.lazy()) и множество .refine() или .transform(), компилятор tsc может замедлять сборку проекта.
Решение: разбивайте крупные схемы на независимые переиспользуемые блоки:
const AddressSchema = z.object({
city: z.string(),
street: z.string(),
});
const ProfileSchema = z.object({
id: z.string(),
address: AddressSchema, // Композиция вместо монолитной структуры
});
Использование .parse() вместо .safeParse() в критических участках
Метод .parse() при ошибке валидации выбрасывает исключение ZodError. Если вызов не обернут в try/catch, это приведет к необработанному падению потока выполнения. Для предсказуемого ветвления логики безопаснее применять .safeParse().
Часто задаваемые вопросы (FAQ)
В чем разница между z.infer<typeof Schema> и z.output<typeof Schema>?
Разницы нет. z.infer — это прямой алиас для z.output. Оба типа возвращают структуру данных после применения всех валидаторов, значений по умолчанию (.default()) и трансформаций (.transform()).
Зачем обязательно писать typeof внутри z.infer<>?
Схема const MySchema = z.object({...}) создается во время выполнения JavaScript и является переменной-значением. Конструкция z.infer<T> ожидает на вход TypeScript-тип этой переменной, поэтому оператор typeof необходим для извлечения типа объекта схемы.
Нужно ли писать отдельные интерфейсы для DTO, если уже есть Zod?
В большинстве случаев нет. Схема Zod служит единым источником правды. Ручное дублирование интерфейсов возвращает проблему рассинхронизации контрактов. Исключение — случаи, когда типы генерируются автоматически сторонними инструментами (например, OpenAPI Generator или GraphQL Codegen).
Как z.infer работает со сложными вложенными объектами и массивами?
Вывод типов происходит рекурсивно. Все модификаторы (z.array(), z.record(), z.optional(), z.nullable()) маппятся в соответствующие структуры TypeScript (массивы, T | undefined, T | null и словари Record<K, V>).
Влияет ли подключение Zod на размер клиентского бандла?
Zod поддерживает Tree Shaking, а его базовая часть весит порядка 12–14 КБ (minified + gzip). Для большинства веб-приложений это допустимый оверхед, который полностью окупается типобезопасностью в рантайме.
Чек-лист по рефакторингу проекта на Schema-First
- Удалите дублирующие
interface: если интерфейс описывает те же данные, что и схема валидации формы или API, замените его наtype Model = z.infer<typeof ModelSchema>. - Проверьте границы приложения (Trust Boundaries): замените касты
as TargetTypeв сетевых запросах и чтении из хранилищ наschema.safeParse(). - Разделите типы форм и обработчиков: если используются
.transform()или значения по умолчанию, используйтеz.input<typeof Schema>для пропсов формы иz.infer<typeof Schema>для функции отправки обработанных данных. - Оптимизируйте монолитные схемы: декомпозируйте большие схемы на переиспользуемые модули для ускорения работы компилятора TypeScript.




.svg.webp)





