Виявлення відхилень у контракті 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
Переписування всіх кінцевих точок одночасно є ризикованим. Безпечніший підхід — це метод «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, продовжували працювати, і щоб кожен крок був достатньо простим для перевірки.
- Найбільша користь проявляється під час рефакторингу, коли компілятор вказує вам усі місця виклику, які постраждають.