Контракт отдельным пакетом: zod, границы, explicit undefined

транспорт≈ 17 минут

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

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

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

Посмотрите, где сейчас живёт схема: t.procedure.input(z.object({ … })) — строкой в файле роутера, рядом с обработчиком. Для сервера это удобно: правило стоит там, где им пользуются. Для клиента этого правила не существует. Чтобы прочитать схему, клиенту пришлось бы импортировать файл роутера, а вместе с ним — сервисы, репозиторий, драйвер базы и половину приложения. Поэтому клиент не импортирует ничего и пишет проверку заново.

Дальше начинается обычная история. В контракте заголовок ограничили двумястами символами, в форме — сотней: кто-то поправил одно место. Список статусов вырос на сервере, на клиенте остался прежний. Форма показывает «всё в порядке», сервер отвечает 400, и пользователь видит ошибку там, где ошибки нет. Лечится это не дисциплиной, а расположением файлов: правило должно лежать в одном месте, откуда его берут оба конца.

Такое место — отдельный пакет. В монорепо учебника он называется @jsp/contracts и устроен до скучного просто: четыре файла, никакого кода приложения, одна зависимость.

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

Две строки здесь стоят объяснения. Зависимость ровно одна: контракт знает про схемы и не знает ни про 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 длины нет вовсе, а во вводе она нужна, и не потому, что база не вынесет, а потому, что заголовок на страницу — это не заголовок. Третье — причины изменений: колонку меняют, когда меняется хранение, а контракт — когда меняется договор с клиентом. Связать их значит получать правку контракта каждый раз, когда переименовали колонку.

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

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

Схема таблицы пропустила authorId: 1 и id: 777 — и не потому, что плохо написана: она честно описывает строку, а в строке эти поля есть. Дальше такой разобранный объект уходит в базу целиком, и заметка оказывается подписана тем, кем попросил клиент. Контракт этих полей не знает, и object собирает новый объект только из объявленных полей — в ответе их просто нет.

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

Они тоже полезны — только не как контракт. В репозитории учебника они лежат в packages/db и строятся drizzle-zod из описания таблиц: createInsertSchema, createSelectSchema, createUpdateSchema. Их работа — ловить расхождение кода и базы раньше, чем его поймает Postgres, и не заставлять переписывать список колонок руками. Граница между двумя видами схем в репозитории объяснена прямо в шапке файла.

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

Последний довод — самый прозаический. Схема, выведенная из таблицы, тащит за собой drizzle-orm: в телефоне это лишние килобайты и лишний повод сломаться на сборке. Контракт обязан оставаться тем, что не стыдно унести в бандл.

Вопрос «можно ли вывести контракт из таблицы» решается не удобством, а тем, кому что позволено. Клиенту позволено меньше, чем строке.

Частичное обновление — место, где контракт становится тонким. Клиент шлёт не всю заметку, а только изменённые поля, и сервер должен различить три случая: поля в теле нет — не трогаем; поле прислано со значением — ставим; поле прислано пустым — очищаем. Для «очищаем» есть null, для «не трогаем» — отсутствие ключа. А undefined не выражает ни того, ни другого, и именно он приезжает чаще всего.

Начнём со схемы. Поле, которого может не быть, описывают обёрткой optional: значение undefined она пропускает, остальное отдаёт внутренней схеме. Выглядит правильно — но object из вашего валидатора обходит объявленные поля, а не присланные, и в разобранный объект ключ попадает всегда. Второй вариант — схема, которая обходит присланные ключи: чего в теле не было, того нет и в ответе.

Прогноз: три тела и по две строки на каждое. Какие ключи окажутся в разобранном значении у пустого тела в первом варианте и во втором — и что после этого станет с профилем?

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

Первый же случай и есть вся беда: тело было пустым, а в разобранном значении ключ name стоит, и сервис послушно затёр имя. Проверка "name" in patch написана верно — врёт ей схема. Вариант с partial ведёт себя ожидаемо: пустое тело — пустой патч, профиль цел.

Обратите внимание и на средний случай: partial на явном undefined отказал. Это решение, а не случайность: раз ключ прислан, значит, клиент что-то хотел сказать, а сказать undefined договор не позволяет — есть null. Можно решить и наоборот: считать явный undefined отсутствием поля. Нельзя одного — не решить: тогда за вас решит библиотека, и в каждом транспорте по-своему.

Насколько по-своему, видно только на проводе. Ниже одно и то же тело едет через обычный JSON и через superjson — тот самый преобразователь, который стоит у tRPC в боевом API, — а на той стороне его разбирает настоящий zod и тот же сервис.

прогон node 26.5.0 · 23 сентября 2026
Одно и то же частичное обновление через JSON и через superjson: что доехало и что решил сервис
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}`,
  };
}
node main.ts

Через 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 у каждой библиотеки, когда тело негодное?

прогон node 26.5.0 · 23 сентября 2026
Один разбор поверх «~standard» на схемах zod и valibot
/**
 * Разбор тела запроса. Про библиотеку схем эта функция не знает ничего:
 * весь её договор с любой схемой — свойство «~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 };
}
node main.ts

Совпало главное: принято или отказано, какие поля виноваты, что доехало до обработчика. Обе схемы обрезали пробелы у заголовка и обе подставили 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 держит названия статусов, чтобы приложения не называли одно и то же по-разному.

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

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

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

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

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

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

Подпись под полем собирается из пути отказа и своего словаря. Сообщение библиотеки — для журнала и для разработчика, а не для пользователя.

В apps/api двенадцать процедур и два контроллера, и входы с ответами у них объявлены схемами из @jsp/contracts. Схемы написаны руками, а общие пределы вынесены в enums.ts: одно число служит и схеме, и подписи поля в форме.

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

Ни authorId, ни createdAt в схеме создания нет, и в роутере рядом с процедурой стоит объяснение: «Автор берётся из токена, а не из входа: иначе заметку можно было бы подписать чужим именем». Это та же граница, что в примере с таблицей, только записанная на боевом коде.

А updateNoteInput — это ровно тот случай с тремя состояниями, и он в репозитории уже сломался однажды. Лечение лежит отдельным файлом на тринадцать строк, и его шапка — готовый разбор происшествия.

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

Сверим это с тем, что видели сами. Цепочка описана точно: 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, не трогая сервер.

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

«Стереть» надо послать значением, а не его отсутствием:

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

Причина в транспорте: JSON.stringify({ photoUrl: undefined }) даёт {} — ключ выброшен, и на сервер приезжает ровно то же тело, что и у «оставить». После правки на проводе {"photoUrl":null}, и сервер печатает «фото очищено».

Через superjson прежний вариант тоже «заработал» бы — и это не довод в его пользу: поведение договора не должно зависеть от того, какой преобразователь включён на транспорте.

Путь до поля из чужой схемы

Форма раскладывает причины по полям, а имя поля берёт из пути отказа. Путь приходит по договору Standard Schema, и шаг в нём бывает двух видов: сам ключ (так его кладёт zod) и объект с полем key (так — valibot). Бывает и отказ вовсе без пути: он относится ко всему телу. Сейчас fieldOf отвечает [object Object] и undefined. Почините его так, чтобы вышли title, author.name, items.0.id и (тело).

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

Путь — массив, и каждый шаг приводится к ключу отдельно:

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

Три вещи здесь обязательны. issue.path ?? [] — потому что поля path у отказа может не быть вовсе. Разбор шага по typeof step === "object" — потому что обе формы законны по договору. И String(step) — потому что ключом бывает число: у массива это индекс.

Обратная задача — разложить готовое имя поля обратно на путь через split(".") — уже ненадёжна: точка бывает и в самом имени поля. Поэтому путь и ездит массивом.

Исход, у которого два поля

Две схемы написаны по одному договору, но отказ складывают по-разному: первая кладёт в исход только причины, вторая — ещё и то, что успела разобрать. Потребитель accept написан на "value" in result и одну из них пропускает мимо проверки. Найдите строку вывода, которая этого не должна была печатать, и почините accept — обе библиотеки трогать нельзя.

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

Лишняя строка — последняя: принято: {"title":42}. Число прошло как заголовок, хотя схема сказала «ожидалась строка».

По договору признак отказа один — непустые issues:

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

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