Глава 26. Схемы как источник истины
Глава 26. Схемы как источник истины
Два определения одной формы
В прошлой главе мы построили мост через границу рантайма — пользовательский предикат, который проверяет форму значения и сообщает результат проверяющей программе. Выглядел он так:
type User = { id: string; name: string };
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof (value as Record<string, unknown>).id === "string" &&
"name" in value && typeof (value as Record<string, unknown>).name === "string"
);
}
Мост работает. Пока работает. Теперь добавим в User поле — почту, которую требует продакт:
type User = { id: string; name: string; email: string };
Тип обновился. Предикат — нет. Компилятор молчит: с его точки зрения isUser по-прежнему возвращает value is User, и он добросовестно поверит этому утверждению. Но проверки email внутри функции нет, и теперь предикат пропускает объекты без почты, клянясь, что это полноценный User. Сужение, которое мы заработали честным трудом, снова лжёт — не потому что мы написали его неверно, а потому что у формы стало два определения, и они разъехались.
const raw: unknown = JSON.parse('{"id":"A-1","name":"Ann"}'); // email нет
if (isUser(raw)) {
raw.email.toLowerCase(); // тип: string. рантайм: Cannot read properties of undefined
}
Вот в чём беда. type User — это утверждение для проверяющей программы. isUser — это проверка для исполняющей. Они описывают одну и ту же форму, но живут в разных слоях языка (Глава 1), и ничто их не связывает. Каждый раз, когда форма меняется, согласовывать два определения приходится вручную, на глаз, и компилятор в этом не помощник — он видит каждое по отдельности и ни одно не сверяет с другим. Это классический источник тихих багов: тип говорит правду, валидатор говорит вчерашнюю правду, и расхождение всплывает в проде на чужих данных.
Вопрос главы простой и почти наглый. Что, если определение одно? Не тип и сверяемый с ним руками валидатор, а единственный артефакт, из которого падают оба — и проверка в рантайме, и тип в компиляторе — так, чтобы разойтись они физически не могли?
Инверсия: валидатор первичен, тип — его тень
Привычный порядок — сперва тип, потом проверка под него. Перевернём его. Сперва опишем валидатор как обычное значение, живущее в рантайме, а тип выведем из него.
Возьмём zod — самую ходовую из библиотек, которые это умеют; к остальным вернёмся ниже. Описание формы выглядит так:
import { z } from "zod";
const User = z.object({
id: z.string(),
name: z.string(),
});
User здесь — не тип. Это обычное значение, константа, которая существует в рантайме и переживает стирание, потому что стирать в ней нечего: z.object возвращает объект-валидатор, у которого есть метод parse. Дайте ему что угодно — он проверит форму по-настоящему, в исполняющей программе:
const raw: unknown = JSON.parse('{"id":"A-1","name":"Ann"}');
const user = User.parse(raw); // проверка происходит в рантайме
// если форма не сошлась — parse бросит ZodError
Это половина дела — рантайм-проверка. Вторая половина: получить из той же константы статический тип, чтобы user был не unknown, а нормально типизирован. Для этого есть z.infer:
type User = z.infer<typeof User>;
// type User = { id: string; name: string }
Остановимся на typeof User. User — значение; typeof User берёт его статический тип (тот, что вывел компилятор для константы-валидатора); z.infer<...> извлекает из этого типа форму, которую валидатор проверяет. Результат — ровно { id: string; name: string }, выведенный, а не написанный руками.
Теперь сложите две строки и посмотрите, что получилось:
const User = z.object({ id: z.string(), name: z.string() }); // значение-валидатор
type User = z.infer<typeof User>; // тип, выведенный из него
Одно определение — z.object(...). Из него растёт и проверка в рантайме (User.parse), и тип в компиляторе (User). И вот ключевое: они не могут разойтись, потому что тип не написан отдельно — он вычислен из валидатора. Добавьте почту в одном месте:
const User = z.object({
id: z.string(),
name: z.string(),
email: z.string(), // добавили здесь — и только здесь
});
User.parse немедленно начинает требовать email в рантайме. z.infer<typeof User> немедленно получает поле email: string. Код, который читал user.email и раньше не компилировался, теперь компилируется; код, который собирал User без почты, перестаёт. Дрейф из начала главы исчез не потому, что мы стали внимательнее, а потому что синхронизировать стало нечего: источник истины один.
Имя User намеренно занято дважды — константой и типом. Это не конфликт: в TypeScript значения и типы живут в разных пространствах имён (Глава 1), и User как значение (валидатор) мирно соседствует с User как типом (форма). На месте употребления компилятор сам разбирает, о ком речь: User.parse(...) — про значение, : User — про тип. Эта двойственность здесь не курьёз, а суть приёма: одно имя, один источник, две проекции.
Чем именно закрывается стирание
Отступим на шаг и назовём, что мы только что построили, в терминах несущей идеи книги.
В Главе 3 типы испаряются перед запуском: в рантайме их нет, спросить у значения его тип не у кого. В Главе 11 на границе мы получали unknown — честное «я не знаю формы», которое требует доказательства, прежде чем с ним можно работать. Схема — это и есть рантайм-представление типа, то самое, которого нас лишило стирание. Валидатор User доживает до исполнения, и потому способен ответить на вопрос, на который сам тип ответить не может: «имеет ли вот это значение форму User?»
parse берёт unknown и превращает его в типизированное значение — состоятельно, по-настоящему, а не утверждением as. Сравните два способа пройти границу:
const raw: unknown = await res.json(); // на границе всегда unknown
const a = raw as User; // ничего не проверил; вера, которая падёт в рантайме
const b = User.parse(raw); // проверил форму; если вернулся — это правда User
as User (Глава 24) переписывает убеждение компилятора, ничего не делая в рантайме: это пробитая дыра, ложь, которую проверяющий принял на слово. User.parse(raw) делает работу — обходит значение, сверяет каждое поле, и либо возвращает значение, про которое тип User теперь заслужен, либо бросает. Сужение из Главы 25 мы получаем ровно то же, но без второго определения, которое надо стеречь.
Что делать с отказом — отдельный выбор, и zod даёт обе двери. parse бросает исключение: удобно там, где невалидные данные — это ошибка, которую ловят выше. safeParse не бросает, а возвращает результат-объект с дискриминантом success — размеченное объединение (Глава 9), которое заставляет разобрать оба исхода:
const result = User.safeParse(raw);
if (result.success) {
result.data.name; // тип: string — здесь форма доказана
} else {
result.error; // ZodError — здесь разбираем провал
}
Какую дверь выбрать — throw или результат-значение — это вопрос модели ошибок, которому посвящена следующая глава (Глава 27). Здесь важно лишь, что выбор есть и он осознанный: схема валидирует, а как именно подать провал — решаете вы.
Это часть IV в работе
Откуда z.infer знает, что z.object({ id: z.string() }) — это { id: string }? Тут нет ни магии, ни рантайм-рефлексии. Это та самая машинерия типов из части IV книги, применённая к делу.
Грубый набросок того, что происходит внутри. Каждый строитель схемы несёт в себе фантомный тип результата — z.ZodString помнит, что его выход string, z.ZodObject<...> помнит форму своих полей. z.infer — это условный тип (Глава 19), который сопоставлением с образцом через infer вытаскивает спрятанную форму, а для объектов проходит по полям сопоставленным типом (Глава 20):
// упрощённо, чтобы показать механику, а не реальный код zod
type Infer<S> =
S extends ZodString ? string :
S extends ZodNumber ? number :
S extends ZodObject<infer Shape> ? { [K in keyof Shape]: Infer<Shape[K]> } :
never;
infer Shape ловит запись полей; { [K in keyof Shape]: Infer<Shape[K]> } рекурсивно превращает каждое поле-валидатор в его тип. Ровно те инструменты, которые мы разбирали по отдельности (Главы 17–23) — дженерики как функции над типами, условные типы, infer, сопоставленные типы, — сложенные в одну библиотечную функцию. Если вы прошли часть IV, z.infer для вас не чёрный ящик: это вывод формы из структуры значения, написанный на языке типов.
Это и есть развязка обещания, данного в начале книги. Типы в TypeScript — не аннотации, а программируемый язык; и вот промышленная библиотека, которой пользуются миллионы, держится целиком на том, что этот язык умеет вычислять форму из другого значения. Без вывода типов схема-первый подход был бы невозможен — пришлось бы писать тип руками, и мы вернулись бы к двум определениям.
Ландшафт и две философии
zod — не единственный и не обязательно лучший выбор; вокруг той же идеи выросла целая поляна, и различия между инструментами поучительны, потому что показывают разные размены под одним принципом.
- Zod — де-факто стандарт: рантайм-DSL (предметный язык описания схем, собранный из обычных вызовов), читаемый API, огромная экосистема. Платишь весом бандла и тем, что вывод тяжёлых схем нагружает компилятор.
- Valibot — та же модель, но собранная из отдельных функций, которые хорошо отрезаются сборщиком (tree-shaking); выигрыш в размере бандла там, где он критичен.
- ArkType — описывает схему синтаксисом, похожим на сами типы TypeScript (
type({ id: "string" })), и компилирует его в быстрый валидатор; ближе всего подходит к ощущению «пишу тип, получаю проверку». - Effect Schema — мыслит не валидацией, а парой кодирования/декодирования (encode/decode): схема описывает обратимое преобразование между внешним и внутренним представлением, что естественно, когда данные на границе не совпадают по форме с доменными.
Все четыре — схема-первый: ты пишешь схему как рантайм-значение, тип выводится из неё (схема → тип). У этого направления есть антипод. Typia идёт в обратную сторону: ты пишешь обычный тип TypeScript, а компилятор-трансформер на этапе сборки порождает из него валидатор (тип → схема). Источник истины снова один — но это сам тип, а проверка генерируется кодом.
Размен между двумя направлениями честный и стоит понимать его до выбора. Схема-первый держит истину в рантайм-значении: никакого шага кодогенерации, обычная библиотека, но форму описываешь на DSL, а не родным синтаксисом типов, и платишь рантайм-весом. Тип-первый держит истину в типе и пишет его родным синтаксисом, но требует шага сборки с трансформером — то есть выходит за пределы стандартного tsc. Одно покупает простоту инструментальной цепочки ценой DSL; другое — родной синтаксис ценой нестандартной сборки. Что из этого ваше, зависит от того, чем вы готовы платить.
Общее у всех схема-первых инструментов — то, ради чего на них и переходят: схемы компонуемы. Это значения, и с ними работают как со значениями — вкладывают, переиспользуют, преобразуют:
const Money = z.number().int().nonnegative(); // сумма в минимальных единицах
const Item = z.object({
sku: z.string(),
qty: z.number().int().positive(),
});
const Order = z.object({
id: z.string(),
total: Money,
status: z.enum(["created", "paid", "shipped", "delivered", "cancelled"]),
items: z.array(Item),
});
type Order = z.infer<typeof Order>;
// { id: string; total: number;
// status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
// items: { sku: string; qty: number }[] }
Money и Item — самостоятельные схемы, вложенные в Order; ту же Money можно поставить на проверку платежа, ту же Item — на проверку корзины. Выведенный Order — в точности каноническая форма заказа из этой книги, только теперь у неё есть рантайм-двойник, который проверяет её на каждой границе, и эти двое не разойдутся никогда.
Глазами джависта
В Java вопрос «как проверить пришедший JSON» решается так буднично, что его и вопросом не считают. У вас есть класс
Order. Jackson привязывает к нему входящий JSON —objectMapper.readValue(json, Order.class)— заполняя поля и спотыкаясь, если форма не сошлась. Хотите правил посложнее — вешаете на поля аннотации Bean Validation (@NotNull,@Positive,@Size), и валидатор отрабатывает их по тому же классу. Источник истины — класс, и тип, и проверка идут от него. Двух определений не возникает, потому что и проверять, и типизировать можно по одной и той же сущности.Возможно, это ровно потому, о чём была Глава 3: в Java тип доживает до рантайма. Класс
Orderреален в исполнении, у объекта есть рантайм-тип, рефлексия видит поля и аннотации — есть по чему проверять. Джавист валидирует против настоящего, физически существующего типа.У TypeScript этого типа в рантайме нет.
Orderстёрт перед запуском, спрашивать форму не у кого. Поэтому проверять приходится против чего-то, что до рантайма доживает, — и это «что-то» надо построить руками. Схема и есть тот самый рантайм-тип, которого язык лишил. А раз его всё равно строить, умный ход — сделать его источником, а статический тип вывести (инверсия), чтобы не держать два определения, как держал бы наивный перенос джавовской привычки «сначала класс, потом всё от него».Получается зеркальная симметрия. Java идёт
тип → проверка, потому что тип реален в рантайме. Идиоматичный TypeScript идётсхема (рантайм-тип) → тип, потому что реальна в рантайме схема, а тип — её тень. Это урок Главы 3, поднятый с уровня одногоinstanceofна уровень архитектуры границы: где Java опирается на доживший тип, TypeScript строит его заново и делает первичным.
Где схемы кусаются
Приём силён, но не бесплатен, и честность требует разложить цену прямо.
Схема становится несущей. Раньше неверным мог оказаться валидатор, а тип всё-таки сторожил. Теперь тип выведен из схемы, и неверная схема даёт уверенно неверный тип — компилятор подтвердит ложь, потому что считает схему истиной. Это не ухудшение: ошибка локализована в одном месте, которое и проверяешь, вместо двух расходящихся. Но иллюзии второго мнения больше нет — единственный источник истины обязан быть верным сам, его никто не подстрахует.
Вывод тяжёлых схем нагружает компилятор. z.infer — это вычисление на типах (часть IV), и на огромных, глубоко вложенных или сильно дженериковых схемах оно упирается в пределы из Главы 22: подсказки в редакторе тормозят, tsc думает дольше, в худших случаях вывод упирается в ограничитель глубины. Лечится дроблением схем на части и иногда явной аннотацией выведенного типа, чтобы компилятор не пересчитывал его каждый раз.
Вход и выход могут не совпадать. Как только в схеме появляется transform или coerce, тип «на входе в parse» и тип «на выходе из parse» расходятся:
const Created = z.object({
// на входе строка, на выходе Date
createdAt: z.string().transform((s) => new Date(s)),
});
type In = z.input<typeof Created>; // { createdAt: string } — принимает строку
type Out = z.output<typeof Created>; // { createdAt: Date } — отдаёт Date
z.infer даёт выходной тип; забыть про входной — значит удивиться, скармливая parse не то, что он принимает. Один источник истины здесь раздваивается на два типа — input и output — и про оба надо помнить.
Валидация стоит времени. parse обходит значение целиком в рантайме. На горячем пути, на крупных массивах, на каждом сообщении высокочастотного потока это заметная цена. Отсюда же — соблазн валидировать всё подряд, включая данные, которые уже прошли границу и доверены: перепроверка внутреннего, уже доказанного значения — чистый шум и трата тактов, а не безопасность.
Схема и внешний .d.ts могут спорить. Если форму того же объекта где-то задаёт декларация чужой библиотеки (Глава 28), а где-то — ваша схема, у вас снова два источника истины, просто разнесённые по разным файлам, и сверять их опять придётся вручную — ровно та беда, от которой схема-первый подход уводил.
Зависимость, DSL, бандл. Вы вносите в проект библиотеку, привязываетесь к её диалекту описания схем и тащите её вес в бандл. А выбор схема-первый против тип-первый — это ещё и выбор инструментальной цепочки: тип-первый (Typia) требует шага сборки с трансформером, то есть жизни вне стандартного tsc. Ни то, ни другое не бесплатно; платить осознанно.
Одна схема на границе, тип — из неё
Вот сдвиг, ради которого глава.
Перестаньте писать форму дважды. Тип и отдельный валидатор под него — это два определения одной истины, и они дрейфуют не по вашей небрежности, а по устройству: ничто их не связывает, синхронизировать надо руками, и рано или поздно рука дрогнет. Пишите одно — схему как рантайм-значение, — а тип выводите из неё. Источник истины становится единственным, и расходиться больше нечему.
Где ставить схемы — там же, где проходит самая крупная дыра из Главы 24: на краях. Внешний JSON, ответ платёжного шлюза, тело запроса, ввод формы, данные из кеша — всё, что приходит в рантайм как unknown без всякой типовой гарантии, разбирается схемой по периметру и входит внутрь уже типизированным значением, чью форму схема доказала. А нутру — данным, которые уже прошли границу и состоятельно типизированы, — доверяйте: перевалидировать их означает не повысить безопасность, а оплатить её дважды. Граница — там, где доказательство нужно; за ней доказательство уже есть.
Как обрабатывать провал — parse с исключением или safeParse с результатом-значением — выбирайте по своей модели ошибок (Глава 27), а не по привычке. И помните, что схема теперь несущая: держите её честной, потому что выведенный тип верен ровно настолько, насколько верна она. Зато ложь, если закрадётся, локализована в одном проверяемом месте, а не размазана по двум определениям, которые расходятся втихую.
Так и стоит читать этот приём — как зрелый ответ на стёртую границу. Глава 3 отняла у нас рантайм-тип; Глава 11 показала unknown, требующий доказательства; Глава 25 научила доказывать руками — и оставила нас с дрейфом двух определений. Схема снимает дрейф, отдавая истину единственному артефакту, который доживает до рантайма, и выводя из него всё остальное. Это нестираемый тип, сделанный правильно: не вопреки стиранию, а с поправкой на него.
Итог
Схема — единственный источник истины: рантайм-значение, которое проверяет форму, и статический тип, который из него выведен и потому не может с ним разойтись. Кто обороняет границу схемой и выводит тип из неё, тот закрывает самую крупную дыру языка без дрейфа и без второго определения, которое пришлось бы стеречь.