Обнаружение отклонений в контракте API во время компиляции с помощью поэтапного внедрения tRPC
tRPC превращает переименованное поле бэкенда в ошибку компиляции TypeScript. Как внедрять его постепенно к каждому эндпоинту рядом с REST, и когда он является неподходящим инструментом.
Источники: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**
Представьте платформу кредитования, где инженер бэкенда меняет название поля applicantName на applicantFullName в ответе на заявку на кредит. API, тесты бэкенда и pull request были обновлены и одобрены, но спустя два дня на экране суммарной информации о кредите отображались пустые имена: никаких исключений, никаких предупреждений в консоли — просто пустая строка, поскольку фронтенд всё ещё читал ключ, который больше не существовал. Обе части приложения были написаны на TypeScript, но никакая информация о типах не передавалась через сеть. Ниже вы увидите, как tRPC закрывает этот разрыв, как его можно постепенно внедрять наряду с REST и когда он не подходит.
Что такое tRPC и чем он не является
tRPC не является протоколом передачи данных и не заменяет REST или GraphQL на уровне транспортировки. Запросы по-прежнему представляют собой обычные HTTP-запросы. Он добавляет слой общих типов: типы возвращаемых значений процедур сервера определяются с помощью TypeScript и напрямую передаются клиенту без использования файлов схем и этапов генерации кода.
Полезной аналогией может служить вызов функции, который выполняется на сервере. Контракт между вызывающим и вызываемым элементом определяется правилами компилятора TypeScript, а не отдельным документом спецификации, который может быть обновлен или оставаться неизменным. Когда сервер меняет формат возвращаемых значений процедуры, все места на клиенте, зависящие от старого формата, перестают компилироваться. Ошибка проявляется прямо в редакторе на той строке, где отсутствует необходимое поле, а не спустя дни в продакшене.
Цена слоя API без типизации
В приведённом выше сценарии слой сервисов фронтенда выглядел примерно так: каждый метод загружал JSON и возвращал его с типом any, а компоненты доверчиво извлекали поля из результата.
// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't
Команда насчитала 47 методов с типом возвращаемого значения any. Каждый из них представлял собой место, где переименование поля, изменение структуры вложенного объекта или изменение значения enum позволяли бы компиляции проходить без ошибок, а проблемы возникали бы только во время выполнения, когда пользователь проверял статус заявки. В финансовом продукте это — не просто косметический баг: отображение неверных или отсутствующих данных клиенту может привести к проблемам с соответствием нормативам.
Миграция с использованием паттерна «strangler fig»
Переписывание всех конечных точек одновременно сопряжено с рисками. Более безопасный подход — это метод постепенной замены: оставить существующие REST-конечные точки нетронутыми, добавить рядом с ними tRPC и постепенно переносить одну конечную точку за другой. Каждая мигрированная конечная точка сразу обретает типобезопасность, а то, что ещё не мигрировано, не требует изменений.
Определение маршрутизатора
На стороне сервера всё начинается с initTRPC.create(), который возвращает объект-строитель, традиционно называемый t. Маршруты могут быть вложенными, поэтому подмаршрут loans объединяет процедуры, связанные с кредитами. Обратите внимание на три момента в этом фрагменте. Во-первых, каждая процедура указывает свои входные данные с помощью схемы Zod, благодаря чему некорректный ID или неизвестный статус отклоняются во время выполнения до того, как запустится обработчик; кроме того, эта же схема предоставляет обработчику типизированный параметр input. Во-вторых, для чтения используется метод query, а для записи — mutation. В-третьих, никто не указывает тип возвращаемого значения вручную: TypeScript определяет его на основе результата запроса к базе данных. Последняя строка экспортирует только тип маршрута, который используется клиентом.
// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;
Есть одна деталь, которую необходимо исправить при копировании этого кода: обработчик выбрасывает TRPCError, который тоже нужно импортировать из @trpc/server. Его отсутствует в строке импорта здесь.
Создание клиента
На фронтенде клиент создается на основе типа AppRouter. Поскольку импорт выполнен с использованием import type, код сервера не попадает в бандл браузера; передаются только информация о типах. httpBatchLink объединяет несколько запросов, сделанных в одном тике, в один HTTP-запрос, а его обработчик headers выполняется для каждого запроса, что делает его удобным местом для добавления токена-носителя.
// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});
Название фабрики отражает используемую версию tRPC: createTRPCProxyClient соответствует API версии v10, тогда как в более новых версиях оно называется createTRPCClient. Проверьте актуальную документацию для установленной вами версии. Общее использование этого типа также предполагает, что клиент может импортировать его, обычно через monorepo или пакет общего рабочего пространства.
Вызов процедуры
Теперь вызов выглядит как обращение к методу локального объекта. Результат полностью типизирован, поэтому обращение к полям, которые сервер больше не возвращает, приводит к ошибке компиляции, а не к незаметному значению undefined.
// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error
Ответы на возражения команды
Ожидайте сопротивления. Обычно возникают три возражения, и каждое из них требует прямого ответа.
- Почему бы не использовать OpenAPI с генерацией кода? Это самое серьезное возражение, поскольку генерируемые клиенты действительно обеспечивают наличие типов. Однако это сопряжено с необходимостью поддерживать схему, выполнять шаг генерации, сохранять генерируемые файлы и соблюдать дисциплину для их синхронизации. В tRPC сам код сервера является единственным источником правды. Если вы предпочитаете подход OpenAPI, ознакомьтесь с инструкцией по генерации типизированного клиента Next.js из NestJS Swagger.
- А что насчет приложений-потребителей, написанных не на TypeScript? В таком случае для работы с API используются некоторые внутренние инструменты на Python. Их концовки остаются в формате REST. Поэтапная миграция делает это простым, поскольку оба стиля могут сосуществовать параллельно.
Когда результаты становятся очевидными
Примерно через шесть недель машине состояний заявки на кредит потребовалась структурная корректировка. Плоское поле состояния было заменено вложенным объектом, отслеживающим как этап рассмотрения, так и результат утверждения:
// Before
type LoanApplication = {
status: 'PENDING' | 'APPROVED' | 'REJECTED';
}
// After
type LoanApplication = {
applicationStatus: {
stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
};
}
При старой схеме каждый компонент, читающий application.status, продолжал бы компилироваться, но выдавал ошибки во время выполнения, и единственным способом защиты был поиск во всем кодовом базисе с последующей надеждой на удачу. С использованием tRPC изменение типа возвращаемого значения функции сразу же выявило 8 мест вызова на фронтенде. Все они были исправлены за один день, а изменения, затрагивающие несколько систем, выпускались без ошибок во время работы. После этого тема обсуждения сместилась от вопросов к миграции на вопрос о том, какой эндпоинт будет следующим.
Результаты через шесть месяцев
- Количество типов возвращаемых значений
anyв слое API сократилось с 47 до 3. - Оставшиеся 3 — это данные вебхуков от сторонних поставщиков без определенных типов; они парсятся с использованием проверенных схем Zod вместо того, чтобы им доверять слепо.
44 устраненных типа any представляют собой скрытые ошибки, у которых так и не появилась возможность проявиться. Чтобы узнать, как избавиться от последних оставшихся проблем, ознакомьтесь с шестью типобезопасными заменами для any.
Где tRPC не подходит
tRPC оправдывает себя только тогда, когда на обоих концах соединения используется TypeScript. Многоязычный бэкенд или публичный API, используемый третьими сторонами, требует явного, независимого от языка контракта, такого как OpenAPI или GraphQL.
Также имейте в виду, что гарантия касается только результатов выполнения кода на этапе компиляции. Zod проверяет входные данные во время выполнения, но определённый автоматически тип возвращаемого значения не выполняет никакой проверки в реальном времени, и возврат сырого записи из базы данных, как это делает приведённый выше маршрутизатор, отправляет все столбцы клиенту. Для конфиденциальных данных выбирайте или задаёте те поля, которые действительно хотите обнародовать.
Основные выводы
- Наличие TypeScript с обеих сторон не эквивалентно наличию типизированного контракта между ними; tRPC создаёт этот недостающий звенько без необходимости использования схемы или этапов генерации кода.
- Мигрируйте эндпоинты по одному рядом с REST, чтобы пользователи, не использующие TypeScript, продолжали работать, и каждый шаг оставался достаточно простым для анализа.
- Самая большая польза проявляется во время рефакторинга, когда компилятор показывает вам все места вызова, затронутые изменениями.