← к блогу

// архитектура

Одна схема на всех: контент-контракт на Zod как источник истины

Обложка статьи: «Одна схема», z.infer

У меня был блог на два сайта: один под наём в штат, второй под фриланс. Контент общий. А жил он как статический массив POSTS в TypeScript-файле, тупо скопированный в оба репозитория.

И в один прекрасный день я замечаю: на одном сайте у поста есть поле featured, а на другом его нет. Один и тот же пост, две разные формы. Источника правды не было вообще: правдой было то, что я последним поправил руками.

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

Откуда вообще берётся рассинхрон

Сразу скажу: рассинхрон типов между сервисом и потребителем — это не лень и не криворукие разработчики. Это структурная проблема. У сервиса свой interface Post, у каждого сайта свой. Три копии одной структуры, и никто не заставляет их совпадать. Добавил поле на бэке — фронт о нём не узнает, пока ты руками не пойдёшь и не допишешь.

TypeScript тут молчит. Он проверяет три файла по отдельности и между собой их не сверяет. А даже если типы случайно сошлись — это всё ещё только компайл-тайм. В рантайме из базы прилетит строка вместо ISO-даты, audiences окажется null, а в теле поста — блок неизвестного типа, который кто-то добавил в обход. TypeScript-тип не ловит ничего из этого: он стирается при сборке, и в проде его просто нет.

Одна схема, типы выводятся сами

Идея контракта проста до банальности. Единственный артефакт — это Zod-схема. TS-типы я вывожу из неё через z.infer, руками не пишу. Схема — источник, тип — производная. Рассинхронить их физически невозможно, потому что тип не существует отдельно от схемы.

const ArticleBlock = z.discriminatedUnion('t', [
  z.object({ t: z.literal('p'), text: z.string() }),
  z.object({ t: z.literal('h'), text: z.string() }),
  z.object({ t: z.literal('quote'), text: z.string() }),
  z.object({ t: z.literal('code'), text: z.string() }),
  z.object({ t: z.literal('list'), items: z.array(z.string()) }),
  z.object({ t: z.literal('video'), src: z.string().url() }),
]);

export const Post = z.object({
  slug: z.string(),
  title: z.string(),
  publishedAt: z.string().datetime(),
  audiences: z.array(z.enum(['main', 'hire'])).nonempty(),
  featured: z.boolean().default(false),
  tags: z.array(z.string()),
  body: z.array(ArticleBlock),
});

export type Post = z.infer<typeof Post>;

Вот эта последняя строка — вся суть. Post-тип теперь просто отражение схемы. Поменял схему — тип поменялся сам, и все потребители тут же увидят несоответствие ещё на этапе компиляции. Дублирования нет, потому что дублировать нечего.

Дискриминированный union для блоков

Тело статьи — это массив блоков разного вида: абзац, подзаголовок, цитата, код, список. У них общее поле-тег t, а остальная форма у каждого своя: у списка есть items, у абзаца — text. Наивно это описывают как одну большую структуру с кучей опциональных полей, и тогда ничто не мешает прислать блок-список без items или абзац с items. Каша. discriminatedUnion по полю t решает это насмерть: Zod по значению тега выбирает нужную ветку и валидирует именно её. На рендере я делаю switch по block.t, и TypeScript внутри каждой ветки точно знает форму: в ветке list есть items, в ветке p есть text, и попытка обратиться к чужому полю просто не скомпилируется. Добавляю блок video в схему — и рендер сразу требует обработать эту ветку, иначе не соберётся.

Валидация на границах

Ключевое слово — границы. Внутри сервиса я доверяю типам и ничего не перепроверяю, это была бы паранойя. Но в каждой точке, где данные пересекают границу системы, я прогоняю их через схему. Таких границ ровно три, и они закрывают весь путь поста.

  • Запись в API: тело POST-запроса парсится схемой до того, как что-либо коснётся базы. Невалидный пост отлетает с понятной ошибкой и в хранилище не попадает.
  • Чтение из БД: то, что вернула Prisma, я тоже валидирую. Звучит избыточно, но именно здесь ловятся старые записи, пережившие миграцию: база не гарантирует, что лежащее в ней соответствует текущему контракту.
  • Фетч на сборке: prebuild-скрипт сайта забирает контент и парсит ответ той же схемой. Сервис отдал мусор или сломанную форму — билд падает на месте, и битый блог до прода не доезжает.

Один parse даёт сразу две гарантии: рантайм-проверку (данные реально такие) и компайл-тайм-тип (после успешного parse переменная типизирована как Post, без всякого as). Это и есть рантайм и компайл-тайм в одном — не два механизма, а один вызов на границе.

Тип, который ты пишешь руками, — это обещание. Схема, через которую проходят данные, — это проверка обещания на границе.

Отдельный пакет и где это оверкилл

Чтобы у схемы был один дом, я вынес её в отдельный пакет — @ishbnv/blog-content-schema. Его импортирует и сервис, и оба сайта — один модуль на всех вместо копии у каждого. Поднять версию пакета — единственный способ изменить форму поста, и изменение разъезжается ко всем потребителям как обычный апдейт зависимости. Не подтянул фронт новую версию — его сборка честно ломается на типах. И это хорошо: молчаливый рассинхрон хуже громкой ошибки. Пакет при этом не тащит ни Hono, ни Prisma, ни базу — чистый Zod и выведенные типы.

Но будем честны: для мелкого проекта это перебор. Один фронт, один бэк, живут в одном репозитории — общий тип в shared-папке закроет почти всю боль почти бесплатно, и городить пакет со схемой незачем. Контракт окупается ровно тогда, когда у одной структуры появляется больше одного независимого потребителя в физически разных местах. Раньше правдой было то, что я последним поправил руками. Теперь правда — это схема, и поспорить с ней нельзя: данные либо проходят границу, либо нет. P.S. В следующий раз разберу, как публикация поста сама пересобирает оба сайта — один webhook, repository_dispatch и ноль ручных деплоев.

P.S. А теперь логичный вопрос — как этот провалидированный контент доезжает до сайта без рантайма? Про доставку на билде против SSR и превью для ботов — отдельно.

Есть похожая задача?

Расскажите — вернусь с оценкой в тот же день.

Обсудить проект

// читать дальше

Кадр из клипа про то, почему ИИ-агенты рисуют серые интерфейсы
0:36
// видео

Почему ИИ-агенты рисуют одинаково серые интерфейсы

Обложка статьи: «Claude Design в проде»
// дизайн6 мин

Как использовать Claude Design в проде

Я разработчик, дизайнер из меня никакой. Но сайт, который ты читаешь, выглядит как надо — дизайн я собрал с Claude, руками не рисовал. Показываю весь путь и артефакты, которые реально пошли в работу.

Обложка статьи: «1 блог, 2 сайта, 1 сервис»
// архитектура8 мин

Два сайта — один блог: как я не уронил SEO, сливая контент в один сервис

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