Собираем валидатор границы: проверка в рантайме и вывод типа

конвейер≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Каркас главы умеет отказывать и умеет приводить вход к нужному виду: страж и труба стоят на своих местах. Только труба, которую вы написали, знает про одно поле и один способ его починить, а на границе настоящего API полей десятки, и про каждое надо сказать две вещи: годится ли оно и какого оно типа. Обычно это пишут дважды — проверку кодом, тип интерфейсом, — и две записи одного правила расходятся молча: компилятор читает интерфейс, а на входе работает код. В этой статье описание станет одно. Схема — обычное значение, которое и проверяет данные в рантайме, и отдаёт компилятору точный тип; из неё же вырастают путь до испорченного поля и список всех причин сразу. Это и есть zod изнутри — а в конце ваша схема научится общему языку, на котором с ней говорит настоящий Nest, и покажет ловушку, на которой этот язык ломается.

Разделы темы:·

Разбор на входе вы писали уже трижды. В типах — функция, у которой внутрь едет unknown, а наружу готовый тип («Разбор вместо проверок по месту»). В части III — тот же разбор поле за полем, где к каждому полю приписывается путь, чтобы сообщение об ошибке можно было переслать тому, кто испортил данные («Поле за полем — и адрес ошибки»). В серверных действиях — «Разобрать, а не поверить», где Number пустой строки оказывался нулём. Всё это работает и здесь: сервер — та же граница, только данных на ней больше.

Начнём с того, что у такого разбора не получается. Ниже parseNote пропускает только два статуса — draft и published. Правило настоящее, оно выполняется на каждом запросе. А в интерфейсе Note у поля status стоит string: компилятор читает интерфейс, и про два статуса он не знает ничего.

Прогноз: три строки вывода. Что напечатает разбор испорченной заметки, у которой пусты title и неверен status, — и что напечатает последняя строка, которую компилятор принял без возражений?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Про испорченную заметку разбор сказал одно слово из двух возможных: он бросает исключение на первой же беде, и про status клиент не узнает, пока не починит title и не пришлёт форму заново. Это первая потеря. Вторая — последняя строка: badge получила статус, которого разбор никогда бы не выпустил, и собралась без единой жалобы. Ветка «неизвестный статус» в ней живёт именно поэтому: компилятор не даст её убрать, ведь для него status — любая строка, и проверки на забытую ветку здесь не выстроить.

Обе потери — из одного корня. Правило записано телом функции: его нельзя ни прочитать, ни передать, ни спросить «а какие вообще бывают статусы». Оно исполняется — и всё. Значит, надо сделать правило значением: тем, что лежит в переменной, ездит в аргументах и умеет рассказать о себе и компилятору тоже.

Разбор и тип — две записи одного правила. Расходятся они молча: компилятор читает вторую, а данные проходят через первую.

Схема — объект с методом check. Он получает значение и путь, по которому мы до этого значения добрались, а возвращает не «да/нет», а исход: либо разобранное значение, либо список причин. Исход — размеченное объединение по полю ok: проверив его, компилятор сам покажет в одной ветке value, в другой — issues, и перепутать их не даст.

Тип схемы параметрический: Schema<string> и Schema<number> — разные типы, и разница видна снаружи. Это не украшение, а весь смысл затеи: раз тип результата записан в типе схемы, его оттуда можно достать. Первый способ достать — самый обычный: вывод параметра из аргумента. Функция parse ниже объявлена с параметром типа — parse<T>(schema: Schema<T>, …): T, — и её результат меняется вместе с переданной схемой.

Прогноз: пять строк. Что окажется в поле value у строки с пробелами по краям и какой путь встанет в причине отказа у дробного числа?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Схема не только отвечает «годится» — она возвращает новое значение. 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, стало частью типа.

Прогноз: четыре строки. Сколько причин приедет в отказе на заметку, у которой испорчены оба поля, и какие у них будут пути? И что ответит схема на значение, которое компилятор пропустил только из-за директивы?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Причин две, и приехали они вместе: 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. Какие коды и что в теле ответа?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Прогноз: три строки. Что напечатает разбор хорошей заметки и как будут выглядеть пути в сообщении об отказе, когда его соберёт потребитель, а не наша труба?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пути стали короче: title вместо body.title. Это правильно — body к пути дописывает тот, кто знает, что схема разбирает именно тело запроса. А parseBody про нашу библиотеку не знает ничего: ни одного её имени в ней нет, и та же функция разберёт схему zod или valibot, если её туда передать.

Обратный перевод в переходнике — место хрупкое, и стоит это признать: item.path.split(".") разложит по точкам и ключ, в котором точка есть по существу. У настоящих библиотек путь массивом с самого начала именно поэтому; у нас он строка, потому что на сервере она сразу идёт в ответ и в журнал. Оба решения имеют цену, и знать её полезнее, чем выбрать «правильное».

Договор — это не библиотека, а одно свойство. Потребитель, который импортирует zod, чтобы прочитать схему, договор не использует.

Теперь сравнение имеет смысл: у вас есть, с чем сравнивать. Совпало устройство, а не название — zod делает ровно то же самое и в том же порядке, только полнее и с сотней мелочей, выясненных на практике.

Узел вашего валидатораКак это зовётся у zodЧто из этого знает Nest 12
Schema<T> с методом checkZodType и 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, разница — в одной строке при старте. Какой код ответа придёт в первом случае и что окажется в теле?

прогон node 26.5.0 · 22 сентября 2026
Один запрос к Nest 12 без зарегистрированной трубы проверки и с ней
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);
nest start

Без трубы — 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 не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обёртка отвечает на единственный вопрос — «значения нет вовсе?» — и во всех остальных случаях передаёт работу вложенной схеме.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тип подтянулся сам: Infer<typeof CreateNote> теперь даёт tag: string | undefined. А ?? "нет" в обработчике теперь не перестраховка: уберите его — и в ответ поедет слово undefined, потому что подстановку string | undefined в шаблонную строку компилятор пропускает молча (проверено). Обратите внимание, чего обёртка НЕ делает: она не отличает «поля нет» от «поле прислали как null». Для null нужна своя схема — и это решение, а не недосмотр: в JSON это два разных сообщения от клиента.

Адрес внутри списка

У заметки появились метки списком. Сейчас схема ждёт в tags строку, поэтому список она отвергает целиком, а одинокую строку принимает. Напишите arrayOf, чтобы список разбирался поэлементно, а в пути у негодного элемента стоял его номер — body.tags[1]. Чужая строка вместо списка должна отвечать «ожидался список».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Устройство то же, что у object: обойти всё, сложить причины, вернуть новый массив.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка const items: unknown[] = value; — не украшение: Array.isArray сужает unknown только до any[], и без этой строки элементы приехали бы в item.check как any. Приём знаком по разбору из части III, где он тоже стоял ровно на этом месте. Вывод после правки: body.tags[1]: не может быть пустым и body.tags: ожидался список.

Лишнее поле

Клиент прислал поле admin, которого в схеме нет. Сейчас оно просто не попадает в разобранный объект — молча. Сделайте схему, которая вместо этого отказывает: body: лишние поля: admin. Менять object нельзя, обёртка — можно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обёртка сверяет ключи до того, как за дело возьмётся object.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А теперь главное: по умолчанию так делать не надо. Молчаливое срезание — это совместимость: клиент новой версии присылает поле, которого старый сервер не знает, и запрос всё равно проходит. Отказ ловит опечатку в имени поля, но ценой того, что каждый новый клиент ломается о старый сервер. У zod срезание тоже стоит по умолчанию, а отказ включается отдельно — z.strictObject({ … }) (прежний .strict() ещё работает, но документация советует первое). Выбор тот же и по тем же причинам.