Accueil / Articles

Détecter les écarts dans le contrat API au moment de la compilation grâce à un déploiement progressif de tRPC

tRPC transforme un champ backend renommé en erreur de compilation TypeScript. Comment le déployer endpoint par endpoint à côté de REST, et pourquoi c’est l’outil inadapté.

Éditeur : REACTAPP.TOP7 min de lecture1681 mots

Sources: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**

Imaginez une plateforme de prêt où un ingénieur backend renomme applicantName en applicantFullName dans la réponse relative à la demande de prêt. L’API, les tests backend ainsi que la demande de fusion sont tous mis à jour et approuvés, mais deux jours plus tard, l’écran de résumé du prêt affiche silencieusement des noms vides : aucune exception, aucun avertissement dans la console, simplement une chaîne vide, car le frontend lit toujours une clé qui n’existe plus. Les deux parties du système ont été écrites en TypeScript, mais aucune information de type n’a traversé la frontière réseau. Vous verrez ci-dessous comment tRPC comble cette lacune, comment l’adopter progressivement en parallèle de REST, et dans quels cas il ne convient pas.

Qu’est-ce que tRPC, et qu’est-ce que ce n’est pas

tRPC n’est pas un protocole de transmission et il ne remplace ni REST ni GraphQL au niveau du transport. Les requêtes restent des appels HTTP ordinaires. Ce qu’il ajoute, c’est une couche de types partagés : les types de retour des procédures serveur sont déduits par TypeScript et transmis directement au client, sans fichier de schéma ni étape de génération de code intermédiaire.

Le coût d’une couche API non typée

Dans le scénario ci-dessus, la couche de service frontend ressemblait à peu près à ceci : chaque méthode récupérait du JSON et le renvoyait en tant que any, et les composants lisaient ses champs en se fiant aveuglément au résultat.

// 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

L’équipe a compté 47 méthodes ayant un type de retour any. Chacune d’elles représentait une occasion où un simple changement de nom de champ, une réorganisation d’un objet imbriqué ou une modification d’une valeur enum aurait permis une compilation sans problème, mais échouait uniquement en temps de exécution, lorsque quelqu’un vérifiait l’état d’une application. Dans un produit financier, cela dépasse le cadre d’un simple bug esthétique : afficher des données incorrectes ou manquantes à un client peut poser des problèmes de conformité.

Migration avec le pattern strangler fig

Réécrire tous les points d’entrée en même temps est risqué. La méthode la plus sûre consiste à utiliser l’approche du figuier étrangleur : laisser les points d’entrée REST existants inchangés, intégrer tRPC à côté d’eux, et migrer un point d’entrée à la fois. Chaque point d’entrée migré bénéficie immédiatement de la sécurité de type, et rien de ce qui n’a pas encore été migré n’a besoin d’être modifié.

Définition du routeur

Le côté serveur commence par initTRPC.create(), qui renvoie un objet constructeur conventionnellement nommé t. Les routeurs peuvent être imbriqués, de sorte qu’un sous-routeur loans regroupe les procédures relatives aux prêts. Remarquez trois éléments dans cet extrait. Premièrement, chaque procédure déclare ses entrées à l’aide d’un schéma Zod, ce qui permet de rejeter un ID mal formaté ou un statut inconnu en temps de exécution avant que le gestionnaire ne soit appelé, et ce même schéma fournit au gestionnaire une variable input de type défini. Deuxièmement, query est utilisé pour les lectures et mutation pour les écritures. Troisièmement, personne n’indique manuellement le type de retour : TypeScript l’infère à partir de ce que renvoie l’appel à la base de données. La dernière ligne exporte uniquement le type du routeur, qui est ce que consomme le client.

// 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;

Un détail à corriger si vous copiez ce code : le gestionnaire lance une exception TRPCError, qui doit également être importée depuis @trpc/server. Elle manque dans la ligne d’import ici.

Création du client

Au niveau frontend, le client est construit à partir du type AppRouter. Comme l’import utilise import type, aucun code serveur n’est inclus dans le bundle du navigateur ; seules les informations de type sont partagées. httpBatchLink combine plusieurs appels effectués dans la même période de temps en une seule requête HTTP, et sa fonction de rappel headers s’exécute pour chaque requête, ce qui constitue un endroit pratique pour y ajouter un jeton porteur.

// 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()}`,
};
},
}),
],
});

Le nom de la fonction reflète la version tRPC en usage : createTRPCProxyClient correspond à l’API v10, tandis que les versions plus récentes l’appellent createTRPCClient. Vérifiez la documentation actuelle correspondant à la version que vous installez. Partager ce type suppose également que le client puisse l’importer, généralement via un monorepo ou un package de espace de travail partagé.

Appel d’une procédure

Un appel ressemble désormais à une méthode sur un objet local. Le résultat est entièrement typé, de sorte que faire référence à un champ que le serveur ne renvoie plus provoque une erreur de compilation plutôt qu’une valeur undefined silencieuse.

// 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

Répondre aux objections de l’équipe

Prévoyez des résistances. Trois objections surviennent généralement, et chacune mérite une réponse directe.

  • Pourquoi pas OpenAPI avec génération de code ? C’est l’objection la plus forte, car les clients générés fournissent bien des types. Le prix à payer est un schéma qui doit être maintenu, une étape de génération qui doit être exécutée, des fichiers générés qui doivent être enregistrés, ainsi qu’une discipline pour garder tout cela synchronisé. Avec tRPC, le code du serveur lui-même constitue la seule source de vérité. Si vous préférez l’approche OpenAPI, découvrez comment générer un client Next.js typé à partir de NestJS Swagger.
  • Et pour les consommateurs qui ne sont pas écrits en TypeScript ? Dans ce cas, quelques outils internes en Python interagissent avec l’API. Leurs points d’accès restent simplement de type REST. Une migration progressive rend cela trivial, puisque les deux styles coexistent côte à côte.
  • Vaut-il la peine de surmonter cette courbe d’apprentissage ? Les routeurs, les procédures et les schémas d’entrée Zod représentent un nouveau vocabulaire. Une démonstration de 30 minutes accompagnée d’un bref guide interne ont suffi pour que l’équipe devienne productive ; les concepts sont peu nombreux, mais cette première session est essentielle.
  • Lorsque les avantages deviennent évidents

    Environ six semaines plus tard, la machine à états des demandes de prêt a nécessité une modification structurelle. Un champ d’état plat a été remplacé par un objet imbriqué permettant de suivre à la fois l’étape d’examen et le résultat de l’approbation :

    // Before
    type LoanApplication = {
    status: 'PENDING' | 'APPROVED' | 'REJECTED';
    }
    // After
    type LoanApplication = {
    applicationStatus: {
    stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
    approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
    };
    }
    

    Au sein de l’ancienne configuration, chaque composant lisant application.status continuait de se compiler avant de planter en temps de exécution, et la seule solution consistait à effectuer une recherche dans l’ensemble du codebase en espérant trouver la cause. Avec tRPC, le changement du type de retour de la procédure a immédiatement identifié 8 points d’appel côté frontend. Tous ont été corrigés en une après-midi, et une modification affectant plusieurs systèmes a pu être déployée sans aucun bug en temps de exécution. Par la suite, le sujet de discussion est passé de remettre en question la migration à se demander quel endpoint serait le suivant à être migré.

    Résultats après six mois

    • Le nombre de types de retour any dans la couche API est passé de 47 à 3.
    • Les 3 types restants correspondent à des payloads de webhook provenant de tiers sans types définis ; ils sont analysés à l’aide de schémas Zod validés plutôt que d’être traités de manière aveugle.
  • Depuis le début de la migration, les points d’entrée migrés n’ont présenté aucun bug lié à des incohérences de type en temps de exécution.
  • 11 erreurs de type ont été détectées à l’étape de compilation, celles qui auraient sinon atteint le environnement de production.
  • Les 44 types any supprimés représentent des bugs latents qui n’ont jamais eu l’occasion de se manifester. Pour savoir comment éliminer les derniers cas restants, consultez six alternatives sûres en termes de types pour remplacer any.

    Où tRPC ne convient pas

    tRPC n’est efficace que lorsque TypeScript est utilisé des deux côtés de la connexion. Un backend polyglotte, ou une API publique consommée par des tiers, nécessite un contrat explicite et indépendant du langage, tel que OpenAPI ou GraphQL.

    N’oubliez pas non plus que la garantie concerne les résultats à l’étape de compilation. Zod valide les entrées en temps de exécution, mais le type de retour déduit ne vérifie rien en temps réel ; renvoyer un enregistrement de base de données brut, comme le fait le routeur ci-dessus, envoie toutes les colonnes au client. Pour des domaines sensibles, sélectionnez ou mappez uniquement les champs que vous souhaitez réellement exposer.

    Points clés

    • Avoir TypeScript des deux côtés ne revient pas à disposer d’un contrat typé entre eux ; tRPC comble cette lacune sans nécessiter de schéma ni d’étape de codegen.
    • Migrez les points d’entrée un par un à côté de REST, afin que les utilisateurs non TypeScript continuent de fonctionner et que chaque étape reste suffisamment petite pour être examinée.
    • Le plus grand avantage se manifeste lors des refacteurs, lorsque le compilateur liste pour vous tous les endroits concernés par les modifications.
  • Lorsque les données représentent de l’argent et des informations clients, ce niveau de sécurité de type est une exigence fondamentale plutôt qu’un luxe, et tRPC permet d’y parvenir sans de nombreux codes génériques.