open atlas
↑ К треку
Система типов TypeScript вглубь TS · 08 · 02

zod: одна схема, статический тип и рантайм-страж

zod — валидация по схеме: одна схема, статический тип через `z.infer`, а `.parse`/`.safeParse` проверяют байты в рантайме и сужают. Тип и страж рождаются из одного определения и не разъедутся — никогда не пишите руками и тип, и схему.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Прошлый урок закончился незакрытым пробелом: JSON.parse(...) as T — всё ещё ложь. Можно написать type guard руками — function isUser(x: unknown): x is User { return typeof x === "object" && x !== null && typeof (x as any).id === "number" && ... } — но теперь вы сопровождаете две вещи, которые обязаны совпадать: интерфейс User и страж. Добавьте поле в интерфейс, забудьте про страж — и вы снова на пейджере в 02:00: тип говорит, что поле есть, страж его не проверяет, каст снова лжёт. Фикс — сделать тип и проверку одним и тем же артефактом. Этот артефакт — zod-схема.

Одна схема, два выхода

Как не дать типу и стражу разъехаться? Ответ — производить оба из одного определения: редактируете одно, и оба обновляются атомарно. Этим единственным определением и является zod-схема.

zod-схема — это рантайм-значение (объект с методом .parse), которое вдобавок несёт достаточно информации о типе, чтобы z.infer извлёк статический тип. Форму вы пишете один раз:

import { z } from "zod";

const User = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  profile: z.object({ bio: z.string() }).nullable(),
});

type User = z.infer<typeof User>;
// ^? { id: number; name: string; email: string; profile: { bio: string } | null }

Тип User и схема User теперь выведены из одного определения. Второй вещи, которую надо синхронизировать, нет. Добавление z.string() для нового поля обновляет и рантайм-проверку, и выведенный тип одной правкой — расхождение структурно невозможно.

Почему это работает

Это прямо чинит граничную ложь из урока 1. Там аннотация типа была обещанием, которое компилятор не мог проверить. Здесь та же схема, что произвела тип, ещё и работает в рантайме против фактических байтов — так что аннотация больше не обещание, а выход проверки. Тип истинен, потому что единственный способ получить типизированный User — пройти .parse.

.parse бросает; .safeParse возвращает размеченное объединение

Два способа запустить проверку на границе:

const res = await fetch("/api/user/42");
const json: unknown = await res.json(); // типизируем как unknown, не any

// .parse — бросает ZodError при отказе
const user = User.parse(json); // ^? User  (или бросает)

// .safeParse — возвращает результат, который надо сузить
const result = User.safeParse(json);
if (result.success) {
  result.data; // ^? User  — сужено, валидировано
} else {
  result.error; // ^? ZodError  — пути + сообщения каждого отказа
}

.safeParse возвращает ровно то размеченное объединение, что вы строили руками в уроке 1, — { success: true; data: User } | { success: false; error: ZodError } — только теперь ветка data заработана настоящей рантайм-проверкой, а не утверждена. Используйте .parse на границах доверия, где хотите громкого падения (серверный маршрут, валидирующий своё же чтение из БД), и .safeParse там, где плохое значение — ожидаемый исход, который вы рендерите (ввод формы, сторонний webhook).

Учтите: z.infer возвращает выходной (output) тип. С трансформами вход и выход различаются; увидим это ниже.

Композиция: вложенность, объединения, размеченные объединения

Схемы компонуются как типы, которые они отражают. Стройте большие формы из маленьких и моделируйте контракт провода успех/ошибка через discriminatedUnion:

const ApiResponse = z.discriminatedUnion("status", [
  z.object({ status: z.literal("ok"), data: User }),
  z.object({ status: z.literal("error"), code: z.number(), message: z.string() }),
]);

type ApiResponse = z.infer<typeof ApiResponse>;
// ^? { status: "ok"; data: User } | { status: "error"; code: number; message: string }

const parsed = ApiResponse.parse(json);
if (parsed.status === "ok") parsed.data; // ^? User

z.discriminatedUnion быстрее и даёт лучшие ошибки, чем обычный z.union, потому что сначала читает дискриминант и валидирует только подходящий член — рантайм-зеркало сужения размеченных объединений, изученного в разделе 02.

Трансформы и уточнения: где вход и выход расходятся

Схема может больше, чем проверять — она может переформировывать и навязывать бизнес-правила:

const DateFromString = z.string().transform((s) => new Date(s));
type In = z.input<typeof DateFromString>;  // ^? string
type Out = z.output<typeof DateFromString>; // ^? Date  (z.infer === output)

const Password = z
  .object({ pw: z.string().min(8), confirm: z.string() })
  .refine((o) => o.pw === o.confirm, { message: "passwords must match", path: ["confirm"] });

transform означает, что значение, которое вы разбираете на входе (строка), отличается от значения, которое получаете на выходе (Date) — поэтому z.infer определён как выходной тип, а z.input существует для формы до трансформа. refine добавляет предикат, который статический тип выразить не может («эти два поля равны»), и привязывает отказ к path поля для отображения в форме (именно так вы подключаете ошибки на уровне полей в работе с формами трека Frontend).

Викторина

Почему выводить `type User = z.infer<typeof User>` лучше, чем писать интерфейс `User` И zod-схему отдельно?

Расставь шаги по порядку

Расставьте поток «схема-первая» от определения до типизированного, провалидированного значения:

  1. 1 Определить zod-схему один раз (z.object, z.discriminatedUnion, уточнения)
  2. 2 Вывести статический тип: type T = z.infer<typeof Schema>
  3. 3 На границе типизировать сырой ввод как unknown и вызвать Schema.safeParse(input)
  4. 4 Сузить по result.success: data — провалидированное значение типа T; иначе читать result.error
Вспомните перед уходом
  1. 01
    Что значит, что zod-схема — «единый источник истины», и как это чинит граничную ложь из урока 1?
  2. 02
    Сравните .parse и .safeParse, включая тип, который даёт каждый.
  3. 03
    Почему z.infer возвращает ВЫХОДНОЙ тип, и где тут трансформы и уточнения?
Итог

Теперь вы делаете тип и рантайм-страж одним артефактом: определяете zod-схему, выводите тип через z.infer, а .parse/.safeParse превращают непроверенные байты в суженное провалидированное значение. Вы компонуете схемы, моделируете контракты провода через discriminatedUnion и переформировываете трансформами и уточнениями. zod закрывает граничную ложь по одному эндпоинту за раз. Дальше tRPC масштабирует идею на всю границу клиент/сервер сразу: вместо валидации каждого ответа клиент выводит весь тип своего API прямо из типа роутера сервера — сквозная типобезопасность без кодогенерации, построенная на той же машинерии вывода, что вы изучали. Теперь, когда встретите рядом рукописный interface и отдельный страж isX, вы знаете: это две вещи, которые рано или поздно разъедутся — и знаете, как слить их в одну схему.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.