open atlas
← Все проекты

fullstack · advanced · 8d

Типобезопасный SDK для API

Собери клиент, которому другие инженеры действительно будут доверять: типизированный SDK поверх настоящего HTTP API, где неправильное поле, пропущенный вариант и ответ, соврамший о своей форме, ловит компилятор — а не падение в проде. Ты смоделируешь домен через размеченные объединения и дженерики, проверишь каждый ответ на границе и дашь выводу типов донести точные типы до места вызова, не оставив ни одного `any`, которым прикрывают дыры.

Типизированный SDK — это место, где TypeScript перестаёт быть украшением и начинает отрабатывать своё содержание. Интересная работа здесь не в том, чтобы рассыпать аннотации, а в том, чтобы решить, где типам позволено лгать, и захлопнуть эту дверь: на сетевой границе, где `unknown` становится доменным типом ровно один раз, за настоящей проверкой в рантайме. Сделай правильно размеченные объединения, ограниченные дженерики и валидируемую границу — и компилятор станет вторым инженером, который ревьюит каждый вызов до запуска, указывая на необработанный вариант, неправильное поле, ответ, сменивший форму. Прокастуйся мимо любого из этого — и ты построил лжеца с хорошей документацией. Сделай честно — и ты построил то, к чему команды тянутся именно потому, что им не нужно думать, безопасно ли это.

Результат

Устанавливаемый TypeScript-SDK с типизированными методами (например, client.users.get(id)), которые валидируют каждый ответ по схеме на сетевой границе, возвращают размеченный Result<T> вместо исключения на ошибках протокола и выводят точные типы возврата в месте вызова — при включённых `noImplicitAny` и `strict` и нуле `any` в публичной поверхности.

Этапы

0/5 · 0%
  1. 01Моделируй домен, а не JSON

    Прежде чем написать хоть один запрос, опиши в типах то, что API реально возвращает. Большинство SDK гниют, потому что вольно повторяют формат провода: один раздутый интерфейс, где всё опционально, и компилятор уже не может сказать, какие поля гарантированы и когда. Сделай наоборот. Используй размеченное объединение для всего, у чего есть варианты — событие вебхука, платёж со статусом `pending | settled | failed`, результат поиска, который бывает `user | repo | issue`, — с литеральным полем `kind` как дискриминантом, чтобы `switch` сужал каждую ветку ровно до её полезной нагрузки. Используй литеральные типы там, где API всегда возвращает фиксированный набор строк. Выгода всплывёт позже: когда ты обрабатываешь значение, TypeScript заставляет учесть каждый вариант, а добавление нового ломает все места, где о нём забыли. В этом и весь смысл объединения вместо «мешка со всем подряд».

    Критерии готовности
    • Хотя бы одна ключевая сущность — это размеченное объединение с литеральным дискриминантом, и `switch` по нему компилируется только когда обработаны все варианты.
    • Добавление гипотетического нового варианта вызывает ошибку компиляции в необработанных местах (проверено через `never`-проверку на исчерпываемость).
  2. 02Дженерик-ядро запроса

    Теперь напиши ту единственную функцию, через которую проходит каждый метод: `request<T>(path, init)`. Хитрость — пропустить ожидаемый тип вызывающего, не солгав. Наивная версия типизирует возврат как `Promise<any>` или кастует распарсенный JSON `as T` — и то и другое молча отдаёт всё, что прислал сервер, не проверив тип ни по чему. Вместо этого сделай дженерик настоящим параметром, который задаёт вызывающий (или выводит схема), и ограничь его так, чтобы проходили только валидные формы: `T extends ApiResource`. Держи функцию честной — она возвращает тело, типизированное как `T`, только после прогона валидации, не раньше. Здесь чувствуется разница между дженериком, который документирует намерение, и кастом, который глушит проверку типов. Один из них переживёт бэкенд, сменивший формат ответа в следующем квартале, другой — нет.

    Критерии готовности
    • Единый дженерик `request<T>` питает все методы SDK, и `T` ограничен — передача несвязанного типа вызывает ошибку компиляции.
    • Нигде в пути запроса нет каста `as T` на распарсенном JSON; типизированное значение выходит из валидации, а не из каста.
  3. 03Валидируй на границе

    Типы стираются в рантайме. Интерфейс `User` ничего не гарантирует о байтах, пришедших по проводу, — сервер может прислать `null` там, где ты написал `string`, выкинуть поле или вернуть страницу ошибки со статусом 200. Поэтому относись к сети как к недоверенному вводу и парси его: задай схему (zod или самописные валидаторы, если хочешь прочувствовать механизм) для каждого ответа, пропусти JSON через неё на границе и только потом впусти значение в свой типизированный мир. Награда в том, что `unknown` становится `User` ровно один раз, в одном месте, с настоящей проверкой за этим, — вместо `any`, разносящего ложь по всему коду ниже. Осознанно реши, что значит «невалидно»: пропущенное опциональное поле — это жёсткий провал или допустимый пробел? Строгость здесь — это выбор дизайна, и ошибиться в нём — значит сделать SDK либо хрупким, либо лжецом.

    Критерии готовности
    • Каждый ответ проходит через парсинг по схеме, прежде чем дойти до типизированного кода; искажённая нагрузка отвергается, а не молча принимается.
    • Граница принимает `unknown` (никогда `any`), и в SDK есть тест, доказывающий, что намеренно искажённый ответ перехватывается.
  4. 04Возвращай результат, а не бросай

    SDK, который бросает исключение на каждый 404, вынуждает вызывающего оборачивать каждый вызов в try/catch и гадать, что вообще лежит в `catch (e: unknown)`, — режим отказа невидим в типе. Смоделируй исход вместо этого. Возвращай размеченный `Result<T>` — `{ ok: true; data: T } | { ok: false; error: ApiError }`, — чтобы компилятор заставлял вызывающего обработать провал прежде, чем коснуться данных; прочитать `result.data`, не сузив сначала `result.ok`, невозможно. Типизируй и сторону ошибки как объединение (сбой сети против сбоя валидации против кода ошибки API), чтобы обработка оставалась исчерпывающей. Продолжай бросать на настоящих багах — ошибка программиста должна падать громко, — но ожидаемые сбои протокола превращай в значения. В этом разница между SDK, чья поверхность говорит правду о том, что может пойти не так, и тем, который прячет это за исключениями, о которых типы молчат.

    Критерии готовности
    • Публичные методы возвращают размеченный `Result<T>`; чтение `data` без сужения `ok` — ошибка компиляции.
    • Ветка ошибки — это объединение, покрывающее как минимум сбой сети, валидации и ошибку API, и оно обработано исчерпывающе в тесте.
  5. 05Сгенерируй типы из схемы

    Писать каждый тип руками нормально для маленького API и медленным сползанием в ложь — для большого: как только бэкенд выкатит поле, которое ты не переписал, твои рукописные типы уверенно неверны. Сеньорский ход — сделать сам контракт API единственным источником истины. Возьми его документ OpenAPI (или JSON Schema) и сгенерируй из него типы TypeScript инструментом вроде openapi-typescript, а затем строй типизированные методы поверх сгенерированной поверхности. Теперь изменение спеки на бэкенде перегенерирует типы, и компилятор укажет на каждое место вызова, которое больше не совпадает, — твой SDK не сможет молча разойтись с реальностью. Встрой генерацию в сборку, чтобы устаревшие типы валили CI, а не уезжали в релиз. Сложное здесь не запуск генератора, а проектирование шва: чтобы твоя рукописная эргономика (приятный `Result<T>`, размеченные объединения) аккуратно оборачивала сгенерированные типы, а не воевала с ними.

    Критерии готовности
    • Типы ответов сгенерированы из документа OpenAPI/JSON Schema, а не переписаны вручную, и перегенерация — это одна команда.
    • Изменение поля в спеке и перегенерация вызывают ошибку компиляции в устаревшем месте вызова.

Стартер

  • README.md
  • src/sdk.ts
  • test/sdk.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Валидация в рантайме (parse, не validate) Ответы кастуются через `as T` или принимаются как `any`; изменённое поле API молча даёт неверные данные в точке вызова. `parse()` прогоняет схему по сырому телу и бросает `ValidationError` при несоответствии; `ValidationError.issues` несёт полный список сбоев, чтобы вызывающий мог вывести понятные сообщения. `parse()` принимает дженерик `Schema<T>` и возвращает `T`, а не `unknown`, только после того как схема подтвердила форму; нигде в валидируемом пути нет каста `as`; параметр типа схемы пронизывает `defineClient.get`, так что тип возврата в точке вызова точный, а не расширенный.
Дизайн конверта ошибок (HttpError vs ValidationError vs сеть) Все сбои всплывают как обычный `Error`; вызывающий не может отличить 404 от несоответствия схемы или таймаута без сравнения строк сообщения. `HttpError` (с `.status` и `.body`) и `ValidationError` (с `.issues`) — отдельные именованные классы; вызывающий может разветвиться через `instanceof` и получить типизированные свойства без каста. Иерархия ошибок допускает исчерпывающую обработку: 4xx никогда не повторять (ошибка вызывающего), 5xx или сетевая ошибка — кандидат на повтор, `ValidationError` означает нарушение контракта, требующее расследования; каждую ветку можно сформулировать и протестировать, ни одна не требует каста или сравнения строк.
Корректность повторов и выдержки `withRetry` повторяет фиксированное число раз с захардкоженной задержкой; часы — настоящий `setTimeout`, так что тесты либо ждут, либо стабируют глобалы. Функция сна инжектируется, делая тесты детерминированными и мгновенными; `backoffDelays` — чистая функция, возвращающая массив, так что вызывающий может проверить или залогировать расписание без побочных эффектов. Ты можешь сформулировать, какие ошибки безопасно повторять (5xx, сеть) и какие нет (4xx, `ValidationError`), и объяснить, почему повтор неидемпотентного POST на 5xx рискует двойным выполнением; в проде ты добавил бы jitter в `backoffDelays`, чтобы избежать конвергенции thundering-herd, — ты можешь набросать формулу и объяснить, почему это важно при массовом сбое.
Эталонный разбор (спойлер)

Parse, не validate: вместо булевой `isUser(data)`, возвращающей true и оставляющей `data` как `unknown`, пиши `Schema<T>`, возвращающую `{ ok: true; value: T }` при успехе — так типизированное значение является прямым результатом проверки, а не отдельным утверждением. Это схлопывает два шага (проверка + каст) в один и делает невозможным использование значения без выполненной проверки.

Какие сбои можно повторять: повторяй 5xx и сетевые ошибки (отказ соединения, таймаут) — сервер отказал временно, и запрос мог не выполниться. Никогда не повторяй 4xx — запрос понят и отклонён; повтор не исправит плохую нагрузку или отсутствующую аутентификацию. Никогда не повторяй `ValidationError` — контракт нарушен, и сервер вернул что-то неожиданное; повтор вернёт те же некорректные данные. Повтор неидемпотентного POST на 5xx особенно опасен: сервер мог выполнить операцию и упасть до отправки ответа, так что повтор может удвоить списание, создание или отправку.

Экспоненциальная выдержка с jitter: базовая формула `baseMs * 2^i` разносит повторы экспоненциально, давая серверу время восстановиться. Без jitter флот клиентов, попавших в одну ошибку одновременно, будет повторять синхронно — thundering herd — усиливая нагрузку именно тогда, когда сервер наиболее уязвим. Добавление равномерного случайного jitter (`delay * Math.random()` или ограниченного случайного значения) размазывает волну повторов. Инжектируй функцию сна, чтобы логика повторов была юнит-тестируемой без реального ожидания — записанный массив задержек доказывает корректность расписания выдержки.

Сетевая граница как шов типов: граница — это единственная точка, где `unknown`-данные провода становятся именованным TypeScript-типом. Пересекай её ровно один раз, в одном месте, с настоящей проверкой в рантайме. Код ниже по течению никогда не должен видеть `unknown` или `any` — он получает типизированное значение, произведённое схемой. Эта дисциплина означает, что если сервер лжёт о форме ответа, программа падает громко на шве, а не тихо в какой-то далёкой точке вызова, предполагавшей наличие поля.

Инжектируемые зависимости делают основную логику тестируемой: `defineClient` принимает `fetchImpl`; `withRetry` принимает `sleep`. Эти швы позволяют тестам подставлять синхронные подделки, записывающие вызовы и возвращающие управляемые значения — без фреймворков моков, без реальной сети, без ожидания настенных часов. Набор приёмочных тестов выполняется за миллисекунды и детерминирован. Это тот же принцип, что внедрение зависимостей в более крупных системах: вынеси I/O на края и держи логику решений чистой.

Сделай по-сеньорски

  • Сделай SDK самодокументируемым: выведи карту методов из путей OpenAPI, чтобы `client.users.get` с типами аргументов и возврата генерировались, а добавление эндпоинта в спеку бесплатно добавляло типизированный метод.
  • Добавь типизированный слой повторов/пагинации, сохраняющий тип элемента между страницами, — дженерик `paginate<T>`, отдающий элементы `T` и не расширяющийся до `unknown` при обходе курсора.
  • Включи самый строгий tsconfig (`exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`) и исправь каждую всплывшую ошибку — доказательство, что типы SDK держатся при максимальной строгости, а не только на дефолтах.

Навыки

modeling a domain with discriminated unionswriting generic functions with constraintsvalidating untrusted input at the boundarycarrying inference to the call sitedesigning a Result type instead of throwinggenerating types from an API schema

Рекомендуемый стек

typescriptzodopenapi-typescriptvitestfetch