У любого запроса к Strapi есть неприятное свойство: форма ответа зависит от того, что вы попросили. Запросили статью без populate — в ответе только скалярные поля. Добавили populate: { category: true } — появилась вложенная категория. Добавили fields: ['name'] внутри populate — категория пришла обрезанной. Один и тот же эндпоинт возвращает десятки разных форм, и статический TypeScript-интерфейс ни одну из них честно не описывает: он либо врёт (обещает поля, которых в этом конкретном ответе нет), либо бесполезен (any).
В этой статье я разберу, как решить эту проблему кодогенерацией: как достать схему из работающего Strapi, сгенерировать по ней TypeScript-клиент и — самое интересное — как заставить систему типов вывести форму ответа прямо из аргумента populate на этапе компиляции. Подход Prisma-подобный: возвращаемый тип метода зависит от того, что вы передали в вызов. Код — на TypeScript; клиент, на примере которого я всё показываю, open-source под MIT, ссылка в конце.
Материал будет полезен, если вы пишете свой кодогенератор над схемой (CMS, БД, чужой API), разбираетесь, как в TypeScript вывести тип из значения аргумента, или просто хотите понять, что происходит под капотом у «типизированных клиентов» вроде Prisma.
Проблема: ручные типы над Strapi всегда врут
Strapi v5 — headless CMS: вы описываете content-типы в админке, а наружу получаете REST/GraphQL. Клиент по умолчанию нетипизирован — fetch('/api/articles') возвращает any, а вся структура ответа живёт только у вас в голове. Дальше начинается знакомое:
Вы заводите руками
interface Articleи он разъезжается со схемой при первом же изменении поля в админке.filters,sort,populate— строки и объекты без всякой проверки: опечатка в имени поля вылезет только в рантайме (или молча вернёт не то).И главное: форма ответа не константна.
article.category— это полноценный объект, если вы его за-populate’или, и его нет вообще, если не populate’или. Обычный интерфейс это выразить не может — тип поля не должен зависеть от аргумента вызова… кроме случая, когда мы умеем такую зависимость запрограммировать.
Ровно эту зависимость «тип результата ← форма аргумента» и нужно построить. Всё остальное — генерация интерфейсов, фильтров, клиента — обвязка вокруг неё.
Как устроено: схема из рантайма, а не из файлов
Первый вопрос — откуда вообще брать схему. Казалось бы, content-типы Strapi лежат в файлах (src/api/*/content-types/*/schema.json), парси и генерируй. На практике это тупик: итоговая схема собирается в рантайме — из ваших типов, из плагинов, из extension’ов, с учётом приватных полей и связей, которые в наружный API не отдаются. Единственный достоверный источник — реестр работающего Strapi: strapi.contentTypes и strapi.components.
Поэтому решение — плагин на стороне Strapi, который читает этот реестр и отдаёт схему наружу:
GET /api/strapi-typed-client/schema → вся схема + hash GET /api/strapi-typed-client/schema-hash → только hash (для детекции изменений)
Плагин по дороге фильтрует лишнее: системные и приватные поля (createdBy, updatedBy, password), admin-связи, admin::* и посторонние plugin::* типы — наружу идёт только то, что реально доступно через публичный API. Роуты объявлены с auth: false, а токен (когда включён requireAuth) валидируется вручную через admin::api-token — иначе Strapi навесил бы на них свой middleware не по адресу.
Дальше — обычный кодогенераторный конвейер:
Strapi plugin (schema API) → CLI fetch → нормализация в IR → ts-morph → prettier → .ts / .js+.d.ts
CLI забирает схему, приводит её к промежуточному представлению (IR), в котором каждый атрибут раскладывается по пяти корзинам — скаляр, связь, медиа, компонент, dynamic zone (это разделение станет ключевым в следующем разделе), — и отдаёт генераторам. Генерация идёт через ts-morph: не конкатенацией строк, а построением AST, поэтому вывод синтаксически валиден по построению и стабильно форматируется.
Отдельно про детерминизм: генератор обязан на одинаковом входе давать побайтово одинаковый выход. Причина — я генерирую код в дерево потребителя и предполагаю, что он коммитится (об этом ниже). Значит, любой недетерминизм — это шум в diff и ложные срабатывания кэша. Порядок из реестра Strapi не гарантирован, поэтому content-типы и компоненты сортируются по UID, а хэш схемы считается от JSON с рекурсивно отсортированными ключами.
Ядро: вывод типа из populate на уровне типов
Теперь самое интересное. Задача: чтобы strapi.articles.find({ populate: { category: true } }) вернул тип, где category — полноценная Category, а strapi.articles.find() — тип, где category вообще нет. Разберём механизм по слоям.
Слой 1. Базовый тип не содержит связей
Первый неочевидный момент: в базовый интерфейс сущности не попадают связи вообще — ни полным объектом, ни даже { id, documentId }. Только скаляры плюс служебные поля:
export interface Article { readonly __typename?: 'Article' id: number documentId: string createdAt: string updatedAt: string title: string slug: string views: number status: 'draft' | 'published' | 'archived' }
Связи, медиа, компоненты и dynamic zones живут отдельно и подмешиваются только в «populated»-версию типа. Побочный, но приятный эффект: обратиться к article.category без populate — это ошибка компиляции, поля просто нет на типе. (Поле __typename — не рудимент GraphQL, а номинальная метка: без неё две структурно одинаковые сущности Strapi путались бы в выводе.)
Слой 2. GetPayload — условный тип, зависящий от аргумента
На каждую сущность генерируется дженерик *GetPayload<P>, который берёт базовый тип и пересекает его с полями, вычисленными из P['populate']:
export type ArticleGetPayload<P extends { populate?: unknown } = {}> = Article & (P extends { populate: infer Pop } ? Pop extends true | '*' ? { category: Category | null; tags: Tag[]; cover: MediaFile | null } : Pop extends readonly (infer _)[] ? { category: 'category' extends Pop[number] ? Category | null : never tags: 'tags' extends Pop[number] ? Tag[] : never } : { category: 'category' extends keyof Pop ? Pop['category'] extends { populate: infer Nested } ? CategoryGetPayload<{ populate: Nested }> | null : Category | null : never } : {})
Три ветки — это три формы, в которых Strapi принимает populate:
Форма |
Пример |
Поведение |
|---|---|---|
Wildcard |
|
все связи подмешиваются целиком |
Массив |
|
только перечисленные |
Объект |
|
по-полю, с рекурсией вглубь |
Ключевые приёмы здесь: infer Pop вытаскивает тип аргумента, 'category' extends keyof Pop проверяет, попросили ли конкретную связь, а вложенный Pop['category'] extends { populate: infer Nested } ? CategoryGetPayload<{ populate: Nested }> — это рекурсия: тип категории считается тем же механизмом, что и родитель, так что populate вложен на любую глубину. Все подмешиваемые поля опциональны, поэтому при P = {} от всей скобки остаётся {} и GetPayload схлопывается в чистый Article.
За обрезку по fields отвечает маленький хелпер:
type _ApplyFields<TFull, TBase, TEntry> = TEntry extends true ? TFull : TEntry extends { fields: readonly (infer F extends string)[] } ? Pick<TBase, Extract<F | 'id' | 'documentId', keyof TBase>> & Omit<TFull, keyof TBase> : TFull
{ category: { fields: ['name'] } } превращается в Pick<Category, 'name' | 'id' | 'documentId'> — модель видит ровно те поля, что запросила.
Слой 3. Связка с методами клиента и трюк с Equal
Метод find объявлен через const-дженерик, чтобы литерал populate сохранил узкий тип и не «размылся»:
find<const TPopulate extends ArticlePopulateParam>( params?: { populate?: TPopulate; filters?: ArticleFilters; /* ... */ }, ): Promise<GetPopulated<Article, TPopulate>[]>
А внутри GetPopulated выбирает нужный *GetPayload — и вот здесь самая тонкая грабля всего механизма:
type GetPopulated<TBase, TPopulate> = Equal<TBase, Article> extends true ? ArticleGetPayload<{ populate: TPopulate }> : Equal<TBase, Category> extends true ? CategoryGetPayload<{ populate: TPopulate }> : TBase
Обратите внимание: Equal<TBase, Article>, а не TBase extends Article. Сущности Strapi часто структурно совместимы (у всех есть id, documentId, createdAt…), и обычный extends начал бы ложно матчить одну на другую — Category «подошла» бы под ветку Article. Поэтому нужен Equal, проверяющий точное равенство типов, а не присваиваемость. Классический капкан условных типов, на который легко не наступить, пока сущностей две, и стабильно наступить, когда их двадцать.
Из const-дженерика вытекает и главная грабля для пользователя. Если вынести populate в переменную без as const, литерал true расширится до boolean, ветка TEntry extends true перестанет срабатывать, и вывод развалится:
const p = { category: true } // тип { category: boolean } — вывод сломан const p2 = { category: true } as const // тип { category: true } — работает
Инлайновый объект as const не требует — TypeScript и так выводит для него литералы.
Грабли, которые всплыли по дороге
Помимо самого вывода типов, набралось расхождений между «как ожидаешь» и «как на самом деле». Самые несущие:
Модуль — ES2022, а не NodeNext. Сгенерированный клиент компилируется с module: ES2022, и это не случайность. NodeNext определяет CJS vs ESM по ближайшему package.json — а ближайший к сгенерированному коду package.json принадлежит потребителю. Если у него "type": "commonjs" (или поля нет), NodeNext истолкует эмитнутый ESM по неверным правилам, и резолюция сломается на чужой машине. ES2022 даёт нейтральный ESM без привязки к формату, а moduleResolution: Bundler разрешает .js-спецификаторы в соседние модули.
Хэш схемы — в первых байтах файла, вне node_modules. Чтобы не регенерировать при каждом запуске, клиент помнит хэш схемы и версию генератора — они запечены первыми экспортами сгенерированного client.ts. Проверка свежести не парсит весь файл (он большой), а читает ровно первые 512 байт и достаёт константы регуляркой. Хранить хэш в node_modules/.cache нельзя принципиально: yarn перезаписывает node_modules при каждом install, кэш бы стирался. Отсюда же — версия генератора в паре с хэшем: апгрейд пакета обязан форсить регенерацию даже при неизменной схеме, иначе новые фиксы эмиттера никогда не доедут до закоммиченного клиента.
__component разрешён только в dynamic zone. Компоненты Strapi встречаются в двух контекстах: как обычный type: "component" атрибут и как блок внутри dynamic zone. В DZ каждый блок обязан нести дискриминатор __component, а в обычном component-поле тот же __component Strapi отвергает с 400. Поэтому на каждый компонент генерируется по два типа: чистый интерфейс (для обычного поля) и *Dz-алиас Component & { __component: 'landing.hero-block' } (для DZ). Дискриминатор даёт бесплатное сужение в switch (block.__component).
Остальное собрал под спойлер — если пишете свой клиент к Strapi, сэкономит время.
Ещё грабли, которые лучше знать заранее
documentId, а не числовойid. В v5 запись адресуется строковымdocumentId(/api/articles/:documentId), числовойidдля этого не годится — методfindOne/update/deleteпринимает именно строку.Плоский вход на create/update. Клиент оборачивает тело в
{ data }сам, а связи в input’е передаются как ID (category: 1), не вложенным объектом. Required-ность форсится только для скаляров — REST-слой Strapi не валидируетrequiredдля связей и компонентов.Unwrap
{ data, meta }. Strapi заворачивает ответ в{ data, meta }; клиент разворачивает:find → T[],findOne → T | null. Когда нужна пагинация, есть отдельныйfindWithMeta, отдающий конверт целиком.users-permissionsне использует{ data }. Эндпоинт/api/usersвозвращает плоский объект без конверта — под него отдельная ветка, гдеwrapBody/unwrapтождественны.Upload-плагин живёт по старым правилам. Он появился до документной модели v5 и молча игнорирует
pagination[page]/[pageSize]— приходится отдавать ему плоскиеstart/limit.Single types. У них нет
documentIdи нет массива:find()возвращает один объект,update(data)идёт без идентификатора, эндпоинт — в единственном числе.
Почему клиент коммитится в репозиторий, а не живёт в node_modules
Ещё одно решение, которое стоит объяснить: сгенерированный клиент пишется в исходное дерево потребителя (флаг --output обязателен), а не прячется внутрь пакета. Bare-экспорта у пакета нет вовсе — из него нельзя import { StrapiClient } from 'strapi-typed-client'.
Это тот же «committed codegen», что у Prisma, и мотивация та же:
типы попадают в git и ревьюятся в PR — изменение схемы видно как обычный diff;
клиент самодостаточен, у приложения нет рантайм-зависимости от генератора (даже
qsвырезан в пользу вендоренной сериализации query, чтобы вывод был zero-dependency);всё переживает reinstall и работает без сети;
в
--format jsна выходе.js+.d.ts, так что даже в plain-JS проекте это работает без билд-шага.
Запись в node_modules явно блокируется — reinstall стёр бы код, а для .ts в node_modules ещё и рантайм отвалился бы (Node не умеет require('.ts')). В связке с Next.js генерация подвешивается на withStrapiTypes: в next dev клиент реагирует на изменения схемы по SSE, в next build — разовая генерация перед сборкой. Пришлось повозиться с изоляцией (динамический import() CLI-кода, чтобы Turbopack не тащил его в бандл; PID-lock, потому что Next.js dev поднимает несколько воркеров и каждый иначе завёл бы свой watcher), но это уже частности интеграции.
Что я вынес
Собрав всё вместе: вы включаете плагин в Strapi, один раз генерируете клиент в свой src/, коммитите — и дальше strapi.articles.find({ populate: { category: true } }) возвращает точный тип с полной category, а article.category.name автокомплитится и проверяется компилятором. Опечатка в имени поля, фильтр по несуществующему атрибуту, обращение к неpopulate’нутой связи — всё это ошибки сборки, а не сюрпризы в проде.
Главные уроки, если будете строить что-то похожее:
Зависимость «тип результата ← аргумент» выражается условными типами.
inferвытаскивает форму аргумента, пересечение подмешивает вычисленные поля, рекурсия через сам*GetPayloadдаёт неограниченную вложенность. А чтобы это дошло до метода — const-дженерики (иas constна стороне пользователя, иначе литералы размываются).extendsв условных типах — не про равенство. Для «этот тип — ровно вот эта сущность?» нуженEqual, иначе структурно близкие типы начнут матчиться друг на друга. Ошибка, которая молчит на маленькой схеме.Источник правды — рантайм, а не файлы. Схему честно отдаёт только работающая система; кодогенератор поверх неё, детерминированный и коммитящий вывод в репо, — рабочая модель, проверенная Prisma.
Клиент написан на TypeScript, распространяется под MIT и лежит в открытом репозитории — если хотите посмотреть, как устроена генерация GetPayload целиком, или взять как основу для своего кодогенератора. Буду рад разбору чужого опыта в комментариях: как вы решаете зависимость «тип ответа ← параметры запроса» в своих клиентах и где условные типы у вас упирались в потолок компилятора?