Собираем валидатор границы: проверка в рантайме и вывод типа
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Каркас главы умеет отказывать и умеет приводить вход к нужному виду: страж и труба стоят на своих местах. Только труба, которую вы написали, знает про одно поле и один способ его починить, а на границе настоящего API полей десятки, и про каждое надо сказать две вещи: годится ли оно и какого оно типа. Обычно это пишут дважды — проверку кодом, тип интерфейсом, — и две записи одного правила расходятся молча: компилятор читает интерфейс, а на входе работает код. В этой статье описание станет одно. Схема — обычное значение, которое и проверяет данные в рантайме, и отдаёт компилятору точный тип; из неё же вырастают путь до испорченного поля и список всех причин сразу. Это и есть zod изнутри — а в конце ваша схема научится общему языку, на котором с ней говорит настоящий Nest, и покажет ловушку, на которой этот язык ломается.
Разбор на входе вы писали уже трижды. В типах — функция, у которой внутрь едет unknown, а наружу готовый тип («Разбор вместо проверок по месту»). В части III — тот же разбор поле за полем, где к каждому полю приписывается путь, чтобы сообщение об ошибке можно было переслать тому, кто испортил данные («Поле за полем — и адрес ошибки»). В серверных действиях — «Разобрать, а не поверить», где Number пустой строки оказывался нулём. Всё это работает и здесь: сервер — та же граница, только данных на ней больше.
Начнём с того, что у такого разбора не получается. Ниже parseNote пропускает только два статуса — draft и published. Правило настоящее, оно выполняется на каждом запросе. А в интерфейсе Note у поля status стоит string: компилятор читает интерфейс, и про два статуса он не знает ничего.
Прогноз: три строки вывода. Что напечатает разбор испорченной заметки, у которой пусты title и неверен status, — и что напечатает последняя строка, которую компилятор принял без возражений?
Про испорченную заметку разбор сказал одно слово из двух возможных: он бросает исключение на первой же беде, и про status клиент не узнает, пока не починит title и не пришлёт форму заново. Это первая потеря. Вторая — последняя строка: badge получила статус, которого разбор никогда бы не выпустил, и собралась без единой жалобы. Ветка «неизвестный статус» в ней живёт именно поэтому: компилятор не даст её убрать, ведь для него status — любая строка, и проверки на забытую ветку здесь не выстроить.
Обе потери — из одного корня. Правило записано телом функции: его нельзя ни прочитать, ни передать, ни спросить «а какие вообще бывают статусы». Оно исполняется — и всё. Значит, надо сделать правило значением: тем, что лежит в переменной, ездит в аргументах и умеет рассказать о себе и компилятору тоже.
Разбор и тип — две записи одного правила. Расходятся они молча: компилятор читает вторую, а данные проходят через первую.
Схема — объект с методом check. Он получает значение и путь, по которому мы до этого значения добрались, а возвращает не «да/нет», а исход: либо разобранное значение, либо список причин. Исход — размеченное объединение по полю ok: проверив его, компилятор сам покажет в одной ветке value, в другой — issues, и перепутать их не даст.
Тип схемы параметрический: Schema<string> и Schema<number> — разные типы, и разница видна снаружи. Это не украшение, а весь смысл затеи: раз тип результата записан в типе схемы, его оттуда можно достать. Первый способ достать — самый обычный: вывод параметра из аргумента. Функция parse ниже объявлена с параметром типа — parse<T>(schema: Schema<T>, …): T, — и её результат меняется вместе с переданной схемой.
Прогноз: пять строк. Что окажется в поле value у строки с пробелами по краям и какой путь встанет в причине отказа у дробного числа?
Схема не только отвечает «годится» — она возвращает новое значение. text отдал строку без пробелов по краям: разбор и приведение к нужному виду здесь одно действие, ровно как у трубы каркаса. Отказ же стал данными: путь, причина, массив. Его можно сложить с чужим отказом, пересчитать, отправить клиенту целиком — с исключением так не выйдет, оно улетает вверх по стеку в одиночку.
А последняя строка примера — про компилятор. count нигде не объявлен как число: его тип приехал из int через parse. Уберите директиву @ts-expect-error со строки ниже — и увидите TS2322 — Тип "number" не может быть назначен для типа "string". Схема уже рассказала компилятору, что выйдет наружу.
Отказ — это данные, а не исключение. Данные складываются в список; исключение всегда одно, и оно всегда первое.
Вывод из аргумента работает, пока схему передают в функцию. Но схему полей надо разобрать в тип объекта: из { title: Schema<string> } должно получиться { title: string }. Для этого нужен способ достать T из Schema<T> прямо в типе, без всякого вызова, — и он есть: infer в условном типе. Три слова, одна строка: type Infer<S> = S extends Schema<infer T> ? T : never.
Дальше — проход по ключам: object принимает объект схем и объявляет результат как { [K in keyof F]: Infer<F[K]> }. Ключи те же, а под каждым — то, что выдаёт схема этого поля. Ни одного слова про title и status в типах писать не придётся: всё это уже сказано схемой.
Второе новое здесь — oneOf. В рантайме это проверка вхождения в список, и только. Вся польза — в типе: T ограничен string и выводится из аргументов, поэтому oneOf("draft", "published") даёт не Schema<string>, а Schema<"draft" | "published">. Правило, которое в первом разделе было спрятано в теле if, стало частью типа.
Прогноз: четыре строки. Сколько причин приедет в отказе на заметку, у которой испорчены оба поля, и какие у них будут пути? И что ответит схема на значение, которое компилятор пропустил только из-за директивы?
Причин две, и приехали они вместе: object не останавливается на первом отказе, а проходит все поля и складывает их issues в один список. Клиент получит разом всё, что надо чинить, — форма подсветит два поля, а не одно. Пути собрались сами: корень назвали body при вызове, а ключ дописала схема объекта.
И то, ради чего всё затевалось: type CreateNote = Infer<typeof CreateNote> — одна строка, после которой у status ровно два значения. Строка с @ts-expect-error это и доказывает: уберите директиву — компилятор скажет Тип ""archived"" не может быть назначен для типа ""draft" | "published"". Ни одного слова про статусы, кроме самой схемы, в файле нет. Теперь и switch по статусу можно закрыть проверкой на забытую ветку: остаток объединения наконец конечен.
Внутри object стоят два as, и это честнее, чем кажется. Первое — value as Record<string, unknown> — снимает unknown после того, как typeof value === "object" и value !== null уже проверены: сузить unknown до «объект с какими-то ключами» иначе нечем.
Второе интереснее. Мы собираем результат в Record<string, unknown>, потому что ключи приходят из Object.keys — строками, в цикле. Компилятор за таким циклом не следит и связать «ключ title получил результат схемы title» не может. Приведение в конце — то единственное место, где обещание держится руками. Держится оно на одной строке цикла, и её стоит перечитать, прежде чем менять: ошибётесь там — компилятор смолчит.
У настоящих библиотек это устроено так же. Внутри zod тоже есть места, где тип держится приведением; разница в том, что таких мест там сотни, а не два, и рядом лежат тесты, которые их держат.
Схема — единственный источник. Если где-то рядом пришлось написать тип руками, он рано или поздно разойдётся со схемой.
Теперь поставим валидатор туда, где он и нужен, — на границу. У каркаса для этого уже есть место: труба, которая получает call и возвращает новый. До сих пор тело в call было строкой — на настоящем сервере её собирает из байтов приёмник тела, а каркасу она приходит уже готовой. Труба-валидатор разбирает эту строку и кладёт на её место значение, у которого есть форма.
Отсюда одна правка в описании каркаса: Call становится параметрическим — Call<B = string>, а Pipe<In, Out> получает и то, что ему приходит, и то, что он отдаёт. Сам каркас при этом не меняется ни на строку: в рантайме труба и так возвращала что угодно, просто в описании об этом не было сказано. Правка не кода, а его описания — и она даёт обработчику тип Call<CreateNote>.
Прогноз: три запроса и три ответа. Один с хорошим телом, один с двумя испорченными полями, один — с телом, которое вообще не JSON. Какие коды и что в теле ответа?
Разбор тела ушёл из обработчика целиком. В create не осталось ни одной проверки — он просто берёт call.body.title, и компилятор знает, что это строка. Поле admin, которое клиент прислал заодно, до обработчика не доехало: object собирает новый объект из описанных полей, а лишнее не копирует. Тот же приём, та же причина — дальше едет ровно то, что описано.
Одного каркас всё же не держит, и об этом надо сказать прямо. Строка create(call: Call<CreateNote>) написана рукой, и с @UsePipes её ничто не связывает: поменяйте схему в декораторе — подпись обработчика останется прежней и соврёт молча. Поэтому в описании UsePipes и стоит any: связи нет, и делать вид, что она есть, хуже, чем признаться. У Nest ровно та же дыра. Закрывается она не декоратором, а другим устройством вызова — тем, из которого потом вырастет клиент, выведенный из типа роутера.
На входе схема защищает вас от чужих данных. На выходе — наоборот: от ваших собственных, и защищает уже клиента. Пользы меньше, а цена та же, поэтому схему ответа ставят редко и обычно ради другого: не «проверить», а «не выпустить лишнее». Хеш пароля, внутренний идентификатор, поле deletedAt — всё это уезжает клиенту, если ответ собирать из строки базы целиком.
Для этого схема и не нужна: достаточно одного места, через которое проходит всякий ответ. Такое место в каркасе появится в статье про перехватчик и фильтр.
Труба меняет не только значение, но и его тип. Если в описании каркаса тело так и осталось строкой, описание отстало от кода.
Валидатор получился свой, и это тупик: труба каркаса умеет читать наши схемы, а чужой код — нет. У экосистемы на этот случай есть договор — Standard Schema. Он разобран на полке React и Next со стороны потребителя: форма, которой всё равно, zod там или valibot. Здесь мы встанем с другой стороны прилавка — сделаем свою схему такой, какую потребитель примет. Сам потребитель в примере ниже почти тот же, что на полке, и разобран там же: нового в этом разделе — переходник.
Весь договор — одно свойство "~standard": номер версии, имя поставщика, функция validate и необязательные types для вывода типа. Переходник поверх нашей схемы ничего в ней не меняет — он только перекладывает исход. Одна строка в нём стоит внимания: путь. У нас путь — готовая строка body.title; у договора — массив ключей от корня схемы. Так сделано не из вредности: массив собирает потребитель, и только он знает, как называется корень и чем разделять уровни.
Прогноз: три строки. Что напечатает разбор хорошей заметки и как будут выглядеть пути в сообщении об отказе, когда его соберёт потребитель, а не наша труба?
Пути стали короче: title вместо body.title. Это правильно — body к пути дописывает тот, кто знает, что схема разбирает именно тело запроса. А parseBody про нашу библиотеку не знает ничего: ни одного её имени в ней нет, и та же функция разберёт схему zod или valibot, если её туда передать.
Обратный перевод в переходнике — место хрупкое, и стоит это признать: item.path.split(".") разложит по точкам и ключ, в котором точка есть по существу. У настоящих библиотек путь массивом с самого начала именно поэтому; у нас он строка, потому что на сервере она сразу идёт в ответ и в журнал. Оба решения имеют цену, и знать её полезнее, чем выбрать «правильное».
Договор — это не библиотека, а одно свойство. Потребитель, который импортирует zod, чтобы прочитать схему, договор не использует.
Теперь сравнение имеет смысл: у вас есть, с чем сравнивать. Совпало устройство, а не название — zod делает ровно то же самое и в том же порядке, только полнее и с сотней мелочей, выясненных на практике.
| Узел вашего валидатора | Как это зовётся у zod | Что из этого знает Nest 12 |
|---|---|---|
Schema<T> с методом check | ZodType и safeParse | ничего: Nest говорит со схемой только через договор |
Infer<typeof CreateNote> | z.infer<typeof CreateNote> | тип параметра пишется рукой, как у вас |
object({ … }) с путями полей | z.object({ … }) | — |
oneOf("draft", "published") | z.enum(["draft", "published"]) | — |
issues списком, а не исключение | result.error.issues | кладёт их строками в message ответа 400 |
труба validated(schema) | — | @Body({ schema }) и StandardSchemaValidationPipe |
свойство "~standard" у схемы | есть и у zod, и у valibot: version — 1 | единственное, что труба у схемы читает |
Последние две строки таблицы — мостик в главу про настоящий Nest, и на нём стоит капкан. В Nest 12 @Body, @Query и @Param принимают объект с полем schema, и выглядит это так, будто проверка включена. Но декоратор только прикладывает схему к метаданным параметра. Проверяет её StandardSchemaValidationPipe, а регистрирует эту трубу приложение — само, строкой app.useGlobalPipes(…). Строки нет — и схема стоит в коде, как украшение.
Прогноз к прогону ниже: одно и то же приложение, один и тот же запрос с пустым title и статусом archived, разница — в одной строке при старте. Какой код ответа придёт в первом случае и что окажется в теле?
import 'reflect-metadata'; import { Body, Controller, Module, Post, StandardSchemaValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import * as z from 'zod'; const CreateNote = z.object({ title: z.string().min(1), status: z.enum(['draft', 'published']), }); @Controller('/notes') class NotesController { @Post() create(@Body({ schema: CreateNote }) body: z.infer<typeof CreateNote>) { return { got: body }; } } @Module({ controllers: [NotesController] }) class AppModule {} // тело, которое схема принять не должна: пустой title и статуса archived в перечислении нет const request = { method: 'POST' as const, url: '/notes', payload: { title: '', status: 'archived' } }; // logger: ['error', 'warn'] — жалобы не прячем; обычные строки старта нам не нужны async function ask(register: boolean) { const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: ['error', 'warn'], }); if (register) app.useGlobalPipes(new StandardSchemaValidationPipe()); await app.init(); const res = await app.inject(request); console.log(`${register ? 'труба зарегистрирована' : 'труба не зарегистрирована'}: ${res.statusCode} ${res.body}`); await app.close(); } await ask(false); await ask(true);
труба не зарегистрирована: 201 {"got":{"title":"","status":"archived"}}
труба зарегистрирована: 400 {"message":["title: Too small: expected string to have >=1 characters","status: Invalid option: expected one of \"draft\"|\"published\""],"error":"Bad Request","statusCode":400}Без трубы — 201 и тело, доехавшее до обработчика как есть: пустой заголовок и статус, которого в перечислении нет. Логгер при этом включён на error и warn, и между двумя ответами он не сказал ни слова — жаловаться некому, потому что никто ничего и не проверял. С трубой тот же запрос отвечает 400 и списком причин с путями полей: title и status. Тот же список, что собирает ваш object, — Nest берёт его у схемы через то самое свойство "~standard".
Потому что в песочнице учебника пакетов нет вовсе. Сторож, который не пускает серверный код под кнопку Run, такой импорт пропустит — он знает про node:* и про список серверных пакетов, а zod живёт и в браузере. Пропустит сторож — откажут компилятор и запуск, каждый по-своему. Компилятор скажет Не удается найти модуль "zod" или связанные с ним объявления типов. Запуск же падает ещё до строки с zod: import делает из примера модуль, а песочница выполняет его скриптом — и первым не находится exports, ReferenceError: exports is not defined.
Поэтому настоящие библиотеки в части VI показываются только записанными прогонами — там точные версии и настоящий node_modules. Прогон выше снят с zod 4.6.5 и Nest 12.0.3: версии входят в запись вместе с выводом, и их видно в подсказке его ярлыка.
Валидатор на этом закончен, а граница — нет. Отказ схемы у нас превращается в 400 со строкой текста, и для «не годится» этого хватает. Но у сервера есть отказы другого рода: «заметка не ваша», «такой уже есть», «срок ссылки вышел» — им нужен код, по которому клиент выберет подпись, а не текст, который он покажет как есть. Этим займётся следующая статья. А сама схема ещё вернётся: в главе про транспорт тот же договор станет контрактом, общим для сервера и клиента («Контракт отдельным пакетом»).
Схема, прикреплённая к параметру, ничего не проверяет сама. Проверяет труба, и зарегистрировать её — отдельное действие.
- Разбор, написанный кодом, и тип, написанный интерфейсом, — две записи одного правила. Компилятор читает вторую, данные идут через первую, и расходятся они молча.
- Схема — значение: объект с
check, который получает значение и путь, а возвращает либо разобранное значение, либо список причин. Отказ как данные складывается в список; исключение всегда одно и всегда первое. - Тип результата живёт в типе схемы, поэтому его можно достать: из аргумента — обычным выводом параметра, из объекта схем —
inferв условном типе и проходом по ключам. Ни одного типа руками. oneOfв рантайме — проверка вхождения в список, а в типе — объединение литералов: правило, которое раньше пряталось в телеif, становится частью типа и закрываетswitchот забытой ветки.- На границе каркаса схема живёт трубой: одна на все маршруты, разные только схемы. Тело в
callперестаёт быть строкой, а связь трубы с типом параметра обработчика каркас не держит — и Nest тоже. - Standard Schema — договор из одного свойства
"~standard", и он в обе стороны: чужой потребитель принимает вашу схему, а Nest 12 читает через него@Body({ schema })— но только если приложение само зарегистрировалоStandardSchemaValidationPipe.
1/4Разбор пропускает только draft и published, но строка badge({ title: "чужая", status: "archived" }) собирается без единой жалобы. Почему?
2/4Что делает type Infer<S> = S extends Schema<infer T> ? T : never?
3/4Схема объекта не останавливается на первом негодном поле, а собирает все причины в список. Что это даёт?
4/4В Nest 12 у параметра стоит @Body({ schema }), а StandardSchemaValidationPipe приложение не регистрировало. Что придёт на тело с пустым title?
Поле, которого может не быть
Метка у заметки необязательна: её можно не присылать вовсе. Сейчас {"title":"Заметка"} получает 400, потому что text считает undefined негодной строкой. Напишите схему-обёртку optional, после которой первый запрос отвечает 200, а третий (метка числом) по-прежнему 400. Каркас, text и object не трогайте.
Обёртка отвечает на единственный вопрос — «значения нет вовсе?» — и во всех остальных случаях передаёт работу вложенной схеме.
Тип подтянулся сам: Infer<typeof CreateNote> теперь даёт tag: string | undefined. А ?? "нет" в обработчике теперь не перестраховка: уберите его — и в ответ поедет слово undefined, потому что подстановку string | undefined в шаблонную строку компилятор пропускает молча (проверено). Обратите внимание, чего обёртка НЕ делает: она не отличает «поля нет» от «поле прислали как null». Для null нужна своя схема — и это решение, а не недосмотр: в JSON это два разных сообщения от клиента.
Адрес внутри списка
У заметки появились метки списком. Сейчас схема ждёт в tags строку, поэтому список она отвергает целиком, а одинокую строку принимает. Напишите arrayOf, чтобы список разбирался поэлементно, а в пути у негодного элемента стоял его номер — body.tags[1]. Чужая строка вместо списка должна отвечать «ожидался список».
Устройство то же, что у object: обойти всё, сложить причины, вернуть новый массив.
Строка const items: unknown[] = value; — не украшение: Array.isArray сужает unknown только до any[], и без этой строки элементы приехали бы в item.check как any. Приём знаком по разбору из части III, где он тоже стоял ровно на этом месте. Вывод после правки: body.tags[1]: не может быть пустым и body.tags: ожидался список.
Лишнее поле
Клиент прислал поле admin, которого в схеме нет. Сейчас оно просто не попадает в разобранный объект — молча. Сделайте схему, которая вместо этого отказывает: body: лишние поля: admin. Менять object нельзя, обёртка — можно.
Обёртка сверяет ключи до того, как за дело возьмётся object.
А теперь главное: по умолчанию так делать не надо. Молчаливое срезание — это совместимость: клиент новой версии присылает поле, которого старый сервер не знает, и запрос всё равно проходит. Отказ ловит опечатку в имени поля, но ценой того, что каждый новый клиент ломается о старый сервер. У zod срезание тоже стоит по умолчанию, а отказ включается отдельно — z.strictObject({ … }) (прежний .strict() ещё работает, но документация советует первое). Выбор тот же и по тем же причинам.