← Глава 25. Type guards и сужение в рантайме Содержание Глава 27. Ошибки и исключения →

Глава 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 — не единственный и не обязательно лучший выбор; вокруг той же идеи выросла целая поляна, и различия между инструментами поучительны, потому что показывают разные размены под одним принципом.

Все четыре — схема-первый: ты пишешь схему как рантайм-значение, тип выводится из неё (схема → тип). У этого направления есть антипод. 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 научила доказывать руками — и оставила нас с дрейфом двух определений. Схема снимает дрейф, отдавая истину единственному артефакту, который доживает до рантайма, и выводя из него всё остальное. Это нестираемый тип, сделанный правильно: не вопреки стиранию, а с поправкой на него.

Итог

Схема — единственный источник истины: рантайм-значение, которое проверяет форму, и статический тип, который из него выведен и потому не может с ним разойтись. Кто обороняет границу схемой и выводит тип из неё, тот закрывает самую крупную дыру языка без дрейфа и без второго определения, которое пришлось бы стеречь.

← Глава 25. Type guards и сужение в рантайме Содержание Глава 27. Ошибки и исключения →