zod: одна схема, статический тип и рантайм-страж
zod — валидация по схеме: одна схема, статический тип через `z.infer`, а `.parse`/`.safeParse` проверяют байты в рантайме и сужают. Тип и страж рождаются из одного определения и не разъедутся — никогда не пишите руками и тип, и схему.
Прошлый урок закончился незакрытым пробелом: 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; // ^? Userz.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 Определить zod-схему один раз (z.object, z.discriminatedUnion, уточнения)
- 2 Вывести статический тип: type T = z.infer<typeof Schema>
- 3 На границе типизировать сырой ввод как unknown и вызвать Schema.safeParse(input)
- 4 Сузить по result.success: data — провалидированное значение типа T; иначе читать result.error
- 01Что значит, что zod-схема — «единый источник истины», и как это чинит граничную ложь из урока 1?
- 02Сравните .parse и .safeParse, включая тип, который даёт каждый.
- 03Почему z.infer возвращает ВЫХОДНОЙ тип, и где тут трансформы и уточнения?
Теперь вы делаете тип и рантайм-страж одним артефактом: определяете zod-схему, выводите тип через z.infer, а .parse/.safeParse превращают непроверенные байты в суженное провалидированное значение. Вы компонуете схемы, моделируете контракты провода через discriminatedUnion и переформировываете трансформами и уточнениями. zod закрывает граничную ложь по одному эндпоинту за раз. Дальше tRPC масштабирует идею на всю границу клиент/сервер сразу: вместо валидации каждого ответа клиент выводит весь тип своего API прямо из типа роутера сервера — сквозная типобезопасность без кодогенерации, построенная на той же машинерии вывода, что вы изучали. Теперь, когда встретите рядом рукописный interface и отдельный страж isX, вы знаете: это две вещи, которые рано или поздно разъедутся — и знаете, как слить их в одну схему.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.