Écrire un petit client tRPC
Cette page a été traduite par PageTurner AI (bêta). Non approuvée officiellement par le projet. Vous avez trouvé une erreur ? Signaler un problème →
Vous êtes-vous déjà demandé comment fonctionne tRPC ? Peut-être souhaitez-vous contribuer au projet mais les rouages internes vous effraient ? L'objectif de cet article est de vous familiariser avec le fonctionnement interne de tRPC en écrivant un client minimal qui couvre les grandes parties de son fonctionnement.
Il est recommandé de comprendre certains concepts fondamentaux de TypeScript comme les génériques, les types conditionnels, le mot-clé extends et la récursivité. Si vous ne maîtrisez pas ces notions, je vous conseille de suivre le tutoriel Beginner TypeScript de Matt Pocock avant de poursuivre.
Aperçu
Supposons que nous ayons un routeur tRPC simple avec trois procédures comme ceci :
tstypePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
tstypePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
L'objectif de notre client est de reproduire cette structure d'objet côté client pour pouvoir appeler des procédures ainsi :
tsconst post1 = await client.post.byId.query({ id: '123' });const post2 = await client.post.byTitle.query({ title: 'Hello world' });const newPost = await client.post.create.mutate({ title: 'Foo' });
tsconst post1 = await client.post.byId.query({ id: '123' });const post2 = await client.post.byTitle.query({ title: 'Hello world' });const newPost = await client.post.create.mutate({ title: 'Foo' });
Pour y parvenir, tRPC utilise une combinaison d'objets Proxy et d'un peu de magie TypeScript pour enrichir la structure avec les méthodes .query et .mutate - ce qui signifie que nous vous MENTONS en réalité sur ce que vous faites (nous y reviendrons) pour offrir une excellente expérience développeur !
Globalement, nous voulons mapper post.byId.query() vers une requête GET sur notre serveur, et post.create.mutate() vers une requête POST, avec une propagation complète des types du back vers le front. Alors, comment procéder ?
Implémentation d'un petit client tRPC
🧙♀️ La magie TypeScript
Commençons par la magie amusante de TypeScript pour débloquer l'autocomplétion géniale et la sécurité de type qu'on adore dans tRPC.
Nous utiliserons des types récursifs pour inférer des structures de routeur arbitrairement profondes. De plus, nous voulons que nos procédures post.byId et post.create aient respectivement les méthodes .query et .mutate - dans tRPC, on appelle cela "décorer" les procédures. Dans @trpc/server, des helpers d'inférence déduisent les types d'entrée/sortie des procédures avec ces méthodes résolues, que nous exploiterons pour typer ces fonctions. Alors, codons !
Voyons ce que nous voulons accomplir pour fournir l'autocomplétion des chemins et l'inférence des types d'entrée/sortie des procédures :
-
Sur un routeur, nous devons accéder à ses sous-routeurs et procédures. (nous y reviendrons)
-
Sur une procédure query, nous devons pouvoir appeler
.query -
Sur une procédure mutation, nous devons pouvoir appeler
.mutate -
Pour tout autre accès, nous voulons une erreur de type indiquant l'absence de la procédure côté serveur
Créons donc un type qui implémente ce comportement :
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
Nous utiliserons des helpers d'inférence internes de tRPC pour définir le type Resolver en déduisant les types d'entrée/sortie des procédures.
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
Testons cela sur notre procédure post.byId :
tstypePostById =Resolver <AppRouter ['post']['byId']>;
tstypePostById =Resolver <AppRouter ['post']['byId']>;
Parfait ! Nous pouvons maintenant appeler .query sur notre procédure et obtenir les types d'entrée/sortie correctement déduits.
Enfin, créons un type qui parcourt récursivement le routeur et décore toutes les procédures rencontrées :
tsimport type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
tsimport type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
Décortiquons un peu ce type :
-
Nous passons un
TRPCRouterRecordcomme générique, qui représente l'ensemble des procédures et sous-routeurs d'un routeur tRPC. -
Nous itérons sur les clés de l'enregistrement, qui sont les noms des procédures ou routeurs, et effectuons les actions suivantes :
- Si la clé correspond à un routeur, nous appelons récursivement le type sur l'enregistrement des procédures de ce routeur, ce qui décorera toutes les procédures de ce routeur. Cela fournira l'autocomplétion lors de la navigation dans le chemin.
- Si la clé correspond à une procédure, nous la décorons en utilisant le type
DecorateProcedurecréé précédemment. - Si la clé ne correspond ni à une procédure ni à un routeur, nous assignons le type
never, ce qui équivaut à dire "cette clé n'existe pas" et générera une erreur de type si on tente d'y accéder.
🤯 Le remaniement par Proxy
Maintenant que nos types sont configurés, nous devons implémenter la fonctionnalité qui enrichira la définition du routeur côté client pour pouvoir invoquer les procédures comme des fonctions normales.
Nous allons d'abord créer une fonction utilitaire pour générer des proxies récursifs - createRecursiveProxy :
Il s'agit quasiment de l'implémentation exacte utilisée en production, à l'exception de certains cas particuliers non gérés ici. Voyez par vous-même !
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
Cela semble un peu magique, comment cela fonctionne-t-il ?
-
La méthode
getgère les accès aux propriétés commepost.byId. La clé (key) correspond au nom de la propriété accédée : lorsque nous taponspost, notrekeyserapost, et pourpost.byIdce serabyId. Le proxy récursif combine ces clés en un chemin final, par exemple ["post", "byId", "query"], que nous utiliserons pour déterminer l'URL à interroger. -
La méthode
applyest appelée lorsque nous invoquons une fonction sur le proxy, comme.query(args). Lesargsreprésentent les arguments passés à la fonction : lors de l'appelpost.byId.query(args), nosargsseront notre input, que nous transmettrons comme paramètres de requête ou corps de requête selon le type de procédure. LecreateRecursiveProxyprend une fonction de rappel que nous mapperons àapplyavec le chemin et les arguments.
Voici une représentation visuelle du fonctionnement du proxy lors de l'appel trpc.post.byId.query({ id: 1 }) :

🧩 Assemblage final
Maintenant que nous avons cet utilitaire et comprenons son fonctionnement, utilisons-le pour créer notre client. Nous fournirons au createRecursiveProxy une fonction de rappel qui prendra le chemin et les arguments pour requêter le serveur via fetch. Nous ajouterons un générique à la fonction acceptant n'importe quel type de routeur tRPC (AnyTRPCRouter), puis nous casterons le type de retour vers le type DecorateRouterRecord créé précédemment :
tsimport {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
tsimport {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
Notons particulièrement que notre chemin utilise des . comme séparateurs plutôt que des /. Cela nous permet d'avoir un unique gestionnaire d'API côté serveur traitant toutes les requêtes, et non un par procédure. Si vous utilisez un framework avec routage basé sur les fichiers comme Next.js, vous reconnaîtrez peut-être le fichier catchall /api/trpc/[trpc].ts qui correspondra à tous les chemins de procédures.
Nous avons également une annotation de type TRPCResponse sur la requête fetch. Cela définit le format de réponse conforme à JSONRPC utilisé par le serveur. Vous pouvez en lire davantage ici. En bref : nous recevons soit un objet result soit un objet error, que nous pouvons utiliser pour déterminer si la requête a réussi et gérer les erreurs appropriées en cas de problème.
Et voilà ! C'est tout le code nécessaire pour appeler vos procédures tRPC côté client comme s'il s'agissait de fonctions locales. En surface, on dirait que nous appelons simplement la fonction de résolution de publicProcedure.query / mutation via des accès normaux aux propriétés, mais nous traversons en réalité une frontière réseau, ce qui nous permet d'utiliser des bibliothèques côté serveur comme Prisma sans exposer les identifiants de base de données.
Testons-le !
Maintenant, créez le client en fournissant l'URL de votre serveur : vous obtiendrez une autocomplétion complète et une sécurité typographique lorsque vous appellerez vos procédures !
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
Le code complet du client est disponible ici, et les tests d'utilisation ici.
Conclusion
J'espère que vous avez apprécié cet article et découvert le fonctionnement interne de tRPC. Vous ne devriez probablement pas utiliser cette version minimaliste au détriment de @trpc/client qui ne pèse que quelques kilo-octets de plus - il offre bien plus de flexibilité que ce que nous montrons ici :
-
Options de requête pour signaux d'annulation, SSR, etc...
-
Liens (Links)
-
Regroupement de procédures (Batching)
-
WebSockets / abonnements
-
Gestion d'erreurs élégante
-
Transformateurs de données
-
Gestion des cas limites comme les réponses non conformes à tRPC
Nous n'avons pas non plus abordé en détail les aspects serveur aujourd'hui - nous les couvrirons peut-être dans un futur article. Si vous avez des questions, n'hésitez pas à me contacter sur Twitter.
