Как сделать типобезопасный клиент для Strapi: разбираем вывод типов из populate на уровне компилятора

от автора

У любого запроса к 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       → вся схема + hashGET /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

populate: true / '*'

все связи подмешиваются целиком

Массив

populate: ['category']

только перечисленные

Объект

populate: { category: { populate: {...} } }

по-полю, с рекурсией вглубь

Ключевые приёмы здесь: 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 целиком, или взять как основу для своего кодогенератора. Буду рад разбору чужого опыта в комментариях: как вы решаете зависимость «тип ответа ← параметры запроса» в своих клиентах и где условные типы у вас упирались в потолок компилятора?

ссылка на оригинал статьи https://habr.com/ru/articles/1059984/