Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Вывод типов из схем Zod: как z.infer устраняет рассинхронизацию типов в TypeScript

Тимофей Ищенко

Коротко: Руководство по выводу типов в 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

  1. Удалите дублирующие interface: если интерфейс описывает те же данные, что и схема валидации формы или API, замените его на type Model = z.infer<typeof ModelSchema>.
  2. Проверьте границы приложения (Trust Boundaries): замените касты as TargetType в сетевых запросах и чтении из хранилищ на schema.safeParse().
  3. Разделите типы форм и обработчиков: если используются .transform() или значения по умолчанию, используйте z.input<typeof Schema> для пропсов формы и z.infer<typeof Schema> для функции отправки обработанных данных.
  4. Оптимизируйте монолитные схемы: декомпозируйте большие схемы на переиспользуемые модули для ускорения работы компилятора TypeScript.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также