Контракт отдельным пакетом: zod, границы, explicit undefined
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Договор границы состоит из трёх частей. Две вы уже разобрали: процедура — это обработчик с именем, звено — проверка перед ним. Третья — схема входа, и до сих пор она была написана прямо в процедуре, то есть внутри сервера. Клиенту она оттуда не видна, и он пишет свою: те же поля, те же пределы, другими словами. Две записи одного правила расходятся молча — ровно так же, как расходились разбор и интерфейс в вашем валидаторе, только теперь между ними ещё и провод. В этой статье схема переедет в отдельный пакет, общий для сервера и клиентов, и по дороге выяснятся три вещи: почему схему нельзя брать из таблицы, что творится с полем, которого «нет», и во что превращается чужое сообщение об ошибке, когда его надо показать человеку.
Посмотрите, где сейчас живёт схема: t.procedure.input(z.object({ … })) — строкой в файле роутера, рядом с обработчиком. Для сервера это удобно: правило стоит там, где им пользуются. Для клиента этого правила не существует. Чтобы прочитать схему, клиенту пришлось бы импортировать файл роутера, а вместе с ним — сервисы, репозиторий, драйвер базы и половину приложения. Поэтому клиент не импортирует ничего и пишет проверку заново.
Дальше начинается обычная история. В контракте заголовок ограничили двумястами символами, в форме — сотней: кто-то поправил одно место. Список статусов вырос на сервере, на клиенте остался прежний. Форма показывает «всё в порядке», сервер отвечает 400, и пользователь видит ошибку там, где ошибки нет. Лечится это не дисциплиной, а расположением файлов: правило должно лежать в одном месте, откуда его берут оба конца.
Такое место — отдельный пакет. В монорепо учебника он называется @jsp/contracts и устроен до скучного просто: четыре файла, никакого кода приложения, одна зависимость.
Две строки здесь стоят объяснения. Зависимость ровно одна: контракт знает про схемы и не знает ни про Nest, ни про базу, ни про HTTP — иначе его нельзя было бы унести в телефон. А поле react-native показывает сборщику приложения исходники вместо dist: в git собранного кода нет, и перед сборкой телефона пакет собирает хук EAS. Тот же пакет берут мобильное приложение и сам API, и это главное свойство контракта: он лежит не «на сервере» и не «на клиенте», а между ними.
Что в нём лежит, видно по именам файлов: enums.ts — списки значений и числовые пределы (NOTE_STATUSES, PASSWORD_MIN), schemas.ts — схемы входа процедур, outputs.ts — схемы ответов, labels.ts — подписи значений для интерфейса. Последнее удивляет: подписи — это же вёрстка. Но если draft называется «Черновик» в одном приложении и «Не опубликовано» в другом, то у пользователя два разных продукта, а не один.
Контракт — это не «папка со схемами». Это пакет с собственным списком зависимостей: он объявляет, чего договору знать не положено.
Соблазн понятен: описание таблицы уже есть, поля в нём те же самые, значит, контракт можно вывести из таблицы и не писать дважды. Тип вставки Drizzle выводит именно так, и схему по таблице тоже умеют строить. Только это будет схема не того договора.
Расходятся они в трёх местах. Первое — поля, которые заполняет сервер: id, authorId, createdAt. В таблице они есть, и схема таблицы их примет; в контракте их быть не должно — иначе клиент подпишет заметку чужим именем. Второе — пределы: в колонке text длины нет вовсе, а во вводе она нужна, и не потому, что база не вынесет, а потому, что заголовок на страницу — это не заголовок. Третье — причины изменений: колонку меняют, когда меняется хранение, а контракт — когда меняется договор с клиентом. Связать их значит получать правку контракта каждый раз, когда переименовали колонку.
Прогноз: четыре строки. Одно и то же тело приходит в схему таблицы и в контракт. Что окажется в разобранном значении у каждой из них — и какая из двух пропустит заголовок в двести пятьдесят символов?
Схема таблицы пропустила authorId: 1 и id: 777 — и не потому, что плохо написана: она честно описывает строку, а в строке эти поля есть. Дальше такой разобранный объект уходит в базу целиком, и заметка оказывается подписана тем, кем попросил клиент. Контракт этих полей не знает, и object собирает новый объект только из объявленных полей — в ответе их просто нет.
С длиной то же самое, но с другой стороны: таблица длинный заголовок примет, контракт — нет. Это и есть обычное соотношение: публичный договор строже хранения. Ограничения ввода защищают от клиента, ограничения колонки — от кода, и живут они по разным правилам.
Они тоже полезны — только не как контракт. В репозитории учебника они лежат в packages/db и строятся drizzle-zod из описания таблиц: createInsertSchema, createSelectSchema, createUpdateSchema. Их работа — ловить расхождение кода и базы раньше, чем его поймает Postgres, и не заставлять переписывать список колонок руками. Граница между двумя видами схем в репозитории объяснена прямо в шапке файла.
Последний довод — самый прозаический. Схема, выведенная из таблицы, тащит за собой drizzle-orm: в телефоне это лишние килобайты и лишний повод сломаться на сборке. Контракт обязан оставаться тем, что не стыдно унести в бандл.
Вопрос «можно ли вывести контракт из таблицы» решается не удобством, а тем, кому что позволено. Клиенту позволено меньше, чем строке.
Частичное обновление — место, где контракт становится тонким. Клиент шлёт не всю заметку, а только изменённые поля, и сервер должен различить три случая: поля в теле нет — не трогаем; поле прислано со значением — ставим; поле прислано пустым — очищаем. Для «очищаем» есть null, для «не трогаем» — отсутствие ключа. А undefined не выражает ни того, ни другого, и именно он приезжает чаще всего.
Начнём со схемы. Поле, которого может не быть, описывают обёрткой optional: значение undefined она пропускает, остальное отдаёт внутренней схеме. Выглядит правильно — но object из вашего валидатора обходит объявленные поля, а не присланные, и в разобранный объект ключ попадает всегда. Второй вариант — схема, которая обходит присланные ключи: чего в теле не было, того нет и в ответе.
Прогноз: три тела и по две строки на каждое. Какие ключи окажутся в разобранном значении у пустого тела в первом варианте и во втором — и что после этого станет с профилем?
Первый же случай и есть вся беда: тело было пустым, а в разобранном значении ключ name стоит, и сервис послушно затёр имя. Проверка "name" in patch написана верно — врёт ей схема. Вариант с partial ведёт себя ожидаемо: пустое тело — пустой патч, профиль цел.
Обратите внимание и на средний случай: partial на явном undefined отказал. Это решение, а не случайность: раз ключ прислан, значит, клиент что-то хотел сказать, а сказать undefined договор не позволяет — есть null. Можно решить и наоборот: считать явный undefined отсутствием поля. Нельзя одного — не решить: тогда за вас решит библиотека, и в каждом транспорте по-своему.
Насколько по-своему, видно только на проводе. Ниже одно и то же тело едет через обычный JSON и через superjson — тот самый преобразователь, который стоит у tRPC в боевом API, — а на той стороне его разбирает настоящий zod и тот же сервис.
import { z } from 'zod'; /** Кусок публичного контракта: частичное обновление профиля. */ export const updateProfile = z.object({ name: z.string().trim().min(1).max(100).optional(), photoUrl: z.string().max(500).nullable().optional(), }); export type UpdateProfile = z.infer<typeof updateProfile>; export type Profile = { name: string; photoUrl: string | null }; /** * Договор полей, записанный в сервисе: поля нет — не трогаем, * null — очищаем, строка — ставим. */ export function applyPatch(profile: Profile, patch: UpdateProfile): { profile: Profile; decision: string } { if (!('photoUrl' in patch)) return { profile, decision: 'фото не трогаем' }; const photoUrl = patch.photoUrl ?? null; return { profile: { ...profile, photoUrl }, decision: photoUrl === null ? 'фото очищено' : `фото ${photoUrl}`, }; }
клиент прислал: поля нет вовсе
JSON на проводе {}
после разбора {}, ключи [] → фото не трогаем
superjson на проводе {"json":{}}
после разбора {}, ключи [] → фото не трогаем
клиент прислал: явный undefined
JSON на проводе {}
после разбора {}, ключи [] → фото не трогаем
superjson на проводе {"json":{"photoUrl":null},"meta":{"values":{"photoUrl":["undefined"]},"v":1}}
после разбора {}, ключи ["photoUrl"] → фото очищено
клиент прислал: null
JSON на проводе {"photoUrl":null}
после разбора {"photoUrl":null}, ключи ["photoUrl"] → фото очищено
superjson на проводе {"json":{"photoUrl":null}}
после разбора {"photoUrl":null}, ключи ["photoUrl"] → фото очищеноЧерез JSON явный undefined не доезжает вовсе: JSON.stringify выбрасывает такие ключи, и «стереть фото» приезжает как «не трогать». Через superjson ключ доезжает — на проводе он выглядит как null плюс пометка в meta, — и сервис принимает решение, противоположное тому, что получилось на соседней строке. Тело одно, схема одна, сервис один; разный только транспорт.
И отдельно — строка про разбор. После zod объект печатается как {}, а Object.keys отвечает ["photoUrl"]: .optional() ключ не удаляет, а JSON.stringify в журнале его не показывает. Ошибка, которой не видно в логах, — худший вид ошибки: в журнале пустое тело, а фото стёрто.
Он попал в стек не ради undefined. Обычный JSON не умеет Date, Map, Set и BigInt: дата уезжает строкой и возвращается строкой, а в ответах API учебника даты объявлены z.date() — createdAt приходит на клиент настоящим Date, и форматировать его можно там, где показывают. Плата за это — тот самый третий случай: раз транспорт умеет возить undefined, договор обязан сказать, что он значит.
Три состояния поля — это три значения на проводе, а не два значения и одно «как получится». Если договор не назвал третье, его назначит транспорт.
Контракт лежит в пакете, и пакет этот попадает и в телефон, и в браузер, и на сервер. Значит, однажды возникнет вопрос про библиотеку схем: zod весит столько-то, у соседа valibot весит меньше, можно ли поменять. Ответ зависит от того, сколько кода вокруг знает имя библиотеки. Переходник к Standard Schema вы уже писали: весь договор — свойство "~standard" с функцией validate внутри. Теперь по обе стороны договора — настоящие библиотеки.
В прогоне ниже одна функция parseBody, которая имени библиотеки не знает. Она ждёт схему с "~standard", зовёт validate и складывает из причин словарь «поле → подпись» — то, что нужно форме. Схем две: zod и valibot, написанные так, чтобы описывать один и тот же договор.
Прогноз: обе схемы получают три одинаковых тела. Совпадут ли выводы построчно — и что окажется в исходе validate у каждой библиотеки, когда тело негодное?
/** * Разбор тела запроса. Про библиотеку схем эта функция не знает ничего: * весь её договор с любой схемой — свойство «~standard». */ export type Standard<T> = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => StandardResult<T> | Promise<StandardResult<T>>; }; }; type StandardResult<T> = { value: T; issues?: undefined } | { issues: readonly StandardIssue[] }; /** Шагом пути договор разрешает быть и самому ключу, и объекту с ключом. */ type StandardIssue = { readonly message: string; readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>; }; export type Parsed<T> = { ok: true; value: T } | { ok: false; errors: Record<string, string> }; const fieldOf = (issue: StandardIssue): string => (issue.path ?? []).map((step) => String(typeof step === 'object' ? step.key : step)).join('.') || '(тело)'; export async function parseBody<T>(schema: Standard<T>, body: unknown): Promise<Parsed<T>> { const result = await schema['~standard'].validate(body); if (result.issues) { const errors: Record<string, string> = {}; // форме нужна одна подпись под полем, поэтому берётся первая причина for (const issue of result.issues) errors[fieldOf(issue)] ??= issue.message; return { ok: false, errors }; } return { ok: true, value: result.value }; }
zod: vendor «zod», версия договора 1
тело {"title":" купить хлеб "}
принято {"title":"купить хлеб","status":"draft"}
тело {"title":" ","status":"archived"}
title: Too small: expected string to have >=1 characters
status: Invalid option: expected one of "draft"|"published"
тело {"title":42}
title: Invalid input: expected string, received number
valibot: vendor «valibot», версия договора 1
тело {"title":" купить хлеб "}
принято {"title":"купить хлеб","status":"draft"}
тело {"title":" ","status":"archived"}
title: Invalid length: Expected >=1 but received 0
status: Invalid type: Expected ("draft" | "published") but received "archived"
тело {"title":42}
title: Invalid type: Expected string but received 42
zod: поля исхода ["issues"]
valibot: поля исхода ["value","typed","issues"]Совпало главное: принято или отказано, какие поля виноваты, что доехало до обработчика. Обе схемы обрезали пробелы у заголовка и обе подставили draft, когда статуса не прислали, — приведение входа входит в договор так же, как проверка. Не совпали сообщения: они написаны библиотекой, по-английски и в её терминах. Это не мелочь, и к ней мы сейчас вернёмся.
Последние две строки — про ловушку в самом договоре. У zod исход отказа состоит из одного поля issues, у valibot — из трёх: рядом с причинами лежит и то, что схема успела разобрать. Потребитель, который написал if ("value" in result), с первой библиотекой работает правильно, а со второй пропускает негодные данные внутрь. Читать исход надо по issues: есть причины — отказ, и никакое value этого не отменяет.
И честная оговорка про сам tRPC. Схему он принимает любую из этих, но через "~standard" разговаривает не со всякой: его разборщик перебирает parseAsync, parse, validateSync, create, assert и только последней ветвью "~standard". У zod есть parseAsync, значит, зовётся он, и в cause отказа приезжает родная ошибка библиотеки. Договор — это про ваш код вокруг схемы, а не про то, как её зовёт транспорт.
Сменить библиотеку схем можно ровно настолько, насколько мало мест знает её имя. Договор из одного свойства и есть способ сделать таких мест одно.
У контракта два потребителя, и до сих пор мы смотрели только на одного. Сервер схемой отказывает; форма той же схемой подсказывает — и ей мало ответа «не годится». Ей нужно знать, какое поле не годится: подпись ложится под свой ввод, а не общей красной строкой над формой.
Адрес поля у отказа уже есть: это путь, который схема ведёт от корня. Подпись — нет, и брать её из сообщения библиотеки не стоит. Сообщения вы видели в прогоне: «Too small: expected string to have >=1 characters». Это отладочная строка, а не текст для человека; к тому же она меняется вместе с версией библиотеки. Поэтому подписи держат рядом с контрактом и выбирают по полю — так же, как labels.ts держит названия статусов, чтобы приложения не называли одно и то же по-разному.
Прогноз: три строки. Что покажет форма на черновике с пустым заголовком и неизвестным статусом — и что случится, когда форма проверит своей схемой, а сервер своей?
Первая строка — обычная работа: причины разложены по полям, каждая со своей подписью, и форма может показать их под нужными вводами. А вторая и третья вместе — та самая история, ради которой контракт и переезжал в пакет. Схема формы знает про заголовок и не знает про статус, поэтому форма говорит «всё в порядке» и отправляет. Сервер отвечает отказом на поле, которого форма даже не показывала.
Отсюда правило: клиент проверяет тем же контрактом. Не копией, не «похожей» схемой, а импортом из того же пакета. В боевом приложении это видно прямо в форме регистрации — предел длины пароля приезжает из контракта и идёт сразу в две стороны: в подпись поля и в проверку перед отправкой.
Комментарий в этом файле отвечает на вопрос, который обычно задают следующим: зачем проверять на клиенте, если сервер всё равно проверит. Затем, что клиентская проверка — это скорость ответа, а не защита. Защищает только серверная: по проводу едет текст, и прислать в нём можно что угодно, минуя любую форму. Поэтому проверок две, и они не дублируют друг друга — у них разные задачи и, благодаря общему пакету, одно правило.
Подпись под полем собирается из пути отказа и своего словаря. Сообщение библиотеки — для журнала и для разработчика, а не для пользователя.
В apps/api двенадцать процедур и два контроллера, и входы с ответами у них объявлены схемами из @jsp/contracts. Схемы написаны руками, а общие пределы вынесены в enums.ts: одно число служит и схеме, и подписи поля в форме.
Ни authorId, ни createdAt в схеме создания нет, и в роутере рядом с процедурой стоит объяснение: «Автор берётся из токена, а не из входа: иначе заметку можно было бы подписать чужим именем». Это та же граница, что в примере с таблицей, только записанная на боевом коде.
А updateNoteInput — это ровно тот случай с тремя состояниями, и он в репозитории уже сломался однажды. Лечение лежит отдельным файлом на тринадцать строк, и его шапка — готовый разбор происшествия.
Сверим это с тем, что видели сами. Цепочка описана точно: superjson довозит ключ, .optional() его не удаляет — обе половины у нас в прогоне напечатаны. Последний шаг стоит уточнить: drizzle сам отбрасывает undefined из .set() (mapUpdateSet в drizzle-orm), и падает не на записи, а когда после отбрасывания не осталось ни одного поля — тогда и появляется «No values to set». То есть боевая беда была не «стёрли чужое фото», а «пятисотка на пустом патче», и это удача: беда, которую видно.
Заметьте и решение, которое там принято: явный undefined отбрасывается, то есть считается отсутствием поля. Наш partial в примере решил наоборот — отказом. Обе трактовки законны, незаконна только третья: не выбрать никакой.
В apps/api и в мобильном приложении стоит superjson версии ^1.13.3, и комментарий рядом объясняет это тем, что вторая версия только ESM, а сборка Nest — CommonJS. Объяснение устарело дважды. Во-первых, основные пакеты Nest 12 сами объявлены "type": "module". Во-вторых, в node 26.5.0 require обычного ES-модуля работает без флага, и superjson 2.2.6 через него берётся.
Держит старую версию другое — тест-раннер. В apps/api стоит jest 29.7.0, который ESM-файл не разбирает вовсе; у 30.5.1 это получается только с NODE_OPTIONS=--experimental-vm-modules. Так что закрепление живое, а причина у него другая, чем написано в файле. Полезная привычка: у закреплённой версии проверять не комментарий, а то, что сломается при снятии закрепления.
Контракт на этом собран: входы, выходы, пределы и подписи лежат в одном пакете, и по обе стороны провода читают их одни и те же. Из этого же пакета вырастает и описание API для посторонних — тех, у кого нет ни вашего клиента, ни ваших типов: OpenAPI поверх той же таблицы метаданных — следующая статья главы.
Комментарий в боевом файле — это протокол давнего расследования. Он говорит, что было верно тогда, и ничего не обещает про сегодня.
- Схема входа, написанная внутри процедуры, видна только серверу: клиент пишет свою копию правила, и копии расходятся молча. Контракт живёт отдельным пакетом с одной зависимостью — его читают оба конца провода.
- Публичный договор строже хранения и не выводится из таблицы: в таблице есть поля, которые заполняет сервер (
authorId,id), а во вводе есть пределы, которых нет в колонке. Схемы по таблице полезны, но остаются на сервере. - Частичное обновление различает три состояния поля: ключа нет — не трогаем,
null— очищаем, значение — ставим. Явныйundefinedне выражает ни одного из них, и договор обязан сказать, чем его считать. - Через JSON явный
undefinedпропадает, черезsuperjsonдоезжает, а.optional()ключ не удаляет — и один и тот же патч меняет решение сервиса в зависимости от транспорта. В журнале при этом видно пустое тело. - Standard Schema делает разбор независимым от библиотеки:
zodиvalibotдают один исход и один список виноватых полей, но свои сообщения. Признак отказа —issues, а не отсутствиеvalue: вторая библиотека кладёт в отказ и то, и другое. - Форме нужен не текст библиотеки, а адрес поля: путь отказа плюс свой словарь подписей. Клиентская проверка ускоряет ответ, защищает только серверная, и правило у них общее ровно потому, что импортируется из одного пакета.
1/4Почему схему входа выносят в отдельный пакет, а не оставляют рядом с процедурой?
2/4Контракт создания заметки собрали из схемы таблицы createInsertSchema. Что с ним не так?
3/4Клиент шлёт { photoUrl: undefined }, чтобы стереть фото. Транспорт — обычный JSON. Что увидит сервер?
4/4Разбор написан поверх "~standard" и проверяет исход как if ("value" in result). Чем это опасно?
Стереть фото
Форма профиля умеет три вещи: оставить фото как есть, стереть его и поставить новое. Тело запроса собирает bodyOf, сервер написан верно и трогает фото по договору «ключа нет — не трогаем, null — очищаем, строка — ставим». Но «стереть» не работает: сервер отвечает так же, как на «оставить». Почините bodyOf, не трогая сервер.
«Стереть» надо послать значением, а не его отсутствием:
Причина в транспорте: JSON.stringify({ photoUrl: undefined }) даёт {} — ключ выброшен, и на сервер приезжает ровно то же тело, что и у «оставить». После правки на проводе {"photoUrl":null}, и сервер печатает «фото очищено».
Через superjson прежний вариант тоже «заработал» бы — и это не довод в его пользу: поведение договора не должно зависеть от того, какой преобразователь включён на транспорте.
Путь до поля из чужой схемы
Форма раскладывает причины по полям, а имя поля берёт из пути отказа. Путь приходит по договору Standard Schema, и шаг в нём бывает двух видов: сам ключ (так его кладёт zod) и объект с полем key (так — valibot). Бывает и отказ вовсе без пути: он относится ко всему телу. Сейчас fieldOf отвечает [object Object] и undefined. Почините его так, чтобы вышли title, author.name, items.0.id и (тело).
Путь — массив, и каждый шаг приводится к ключу отдельно:
Три вещи здесь обязательны. issue.path ?? [] — потому что поля path у отказа может не быть вовсе. Разбор шага по typeof step === "object" — потому что обе формы законны по договору. И String(step) — потому что ключом бывает число: у массива это индекс.
Обратная задача — разложить готовое имя поля обратно на путь через split(".") — уже ненадёжна: точка бывает и в самом имени поля. Поэтому путь и ездит массивом.
Исход, у которого два поля
Две схемы написаны по одному договору, но отказ складывают по-разному: первая кладёт в исход только причины, вторая — ещё и то, что успела разобрать. Потребитель accept написан на "value" in result и одну из них пропускает мимо проверки. Найдите строку вывода, которая этого не должна была печатать, и почините accept — обе библиотеки трогать нельзя.
Лишняя строка — последняя: принято: {"title":42}. Число прошло как заголовок, хотя схема сказала «ожидалась строка».
По договору признак отказа один — непустые issues:
Поле value в исходе отказа договор не запрещает: библиотека вправе показать, что успела разобрать. Проверка «есть ли value» поэтому и не годится — она случайно работает с одной библиотекой и молча ломается с другой. Ровно так и выглядит цена того, что имя библиотеки знает не одно место, а каждое.