Bygga en minimal tRPC-klient
Denna sida har översatts av PageTurner AI (beta). Inte officiellt godkänd av projektet. Hittade du ett fel? Rapportera problem →
Har du någonsin undrat hur tRPC fungerar? Kanske du vill börja bidra till projektet men är skrämd av internals? Syftet med detta inlägg är att bekanta dig med tRPC:s interna delar genom att bygga en minimal klient som täcker de stora delarna av hur tRPC fungerar.
Det rekommenderas att du förstår några grundläggande koncept i TypeScript som generics, villkorsstyper, nyckelordet extends och rekursion. Om du inte är bekant med dessa rekommenderar jag att du går igenom Matt Pococks Beginner TypeScript-tutorial för att sätta dig in i dessa koncept innan du fortsätter läsa.
Översikt
Låt oss anta att vi har en enkel tRPC-router med tre procedurer som ser ut så här:
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 ;}),}),});
Målet med vår klient är att efterlikna denna objektstruktur på klientsidan så att vi kan anropa procedurer så här:
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' });
För att uppnå detta använder tRPC en kombination av Proxy-objekt och TypeScript-magik för att utöka objektstrukturen med .query och .mutate-metoderna - vilket innebär att vi faktiskt LJUGR för dig om vad du gör (mer om detta senare) för att ge en fantastisk utvecklarupplevelse!
På en hög nivå vill vi mappa post.byId.query() till en GET-förfrågan till vår server, och post.create.mutate() till en POST-förfrågan, samtidigt som typerna propageras från backend till frontend. Så, hur gör vi detta?
Implementera en minimal tRPC-klient
🧙♀️ TypeScript-magirin
Låt oss börja med den roliga TypeScript-magirin för att låsa upp den fantastiska autofyllningen och typsäkerheten vi alla känner och älskar från att använda tRPC.
Vi behöver använda rekursiva typer för att kunna inferera godtyckligt djupa routerstrukturer. Vi vet också att vi vill att våra procedurer post.byId och post.create ska ha .query respektive .mutate-metoder - i tRPC kallar vi detta för att dekorera procedurerna. I @trpc/server har vi några inferenshjälpare som infererar input- och outputtyper för våra procedurer med dessa metoder, vilket vi kommer använda för att inferera typerna för dessa funktioner - så låt oss skriva lite kod!
Låt oss överväga vad vi vill uppnå för att tillhandahålla autofyllning av sökvägar samt inferens av procedurernas input- och outputtyper:
-
Om vi befinner oss på en router vill vi kunna komma åt dess underrouter och procedurer (vi återkommer till detta strax)
-
Om vi befinner oss på en query-procedure vill vi kunna anropa
.querypå den -
Om vi befinner oss på en mutation-procedure vill vi kunna anropa
.mutatepå den -
Om vi försöker komma åt något annat vill vi få ett typfel som indikerar att proceduren inte finns på servern
Så låt oss skapa en typ som gör detta åt oss:
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;
Vi kommer använda några av tRPC:s inbyggda inferenshjälpare för att inferera input- och outputtyperna för våra procedurer när vi definierar Resolver-typen.
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 >>;
Låt oss testa detta på vår post.byId-procedure:
tstypePostById =Resolver <AppRouter ['post']['byId']>;
tstypePostById =Resolver <AppRouter ['post']['byId']>;
Bra, det är precis vad vi förväntade oss - vi kan nu anropa .query på vår procedure och få korrekta infererade input- och outputtyper!
Slutligen skapar vi en typ som rekursivt traverserar routern och dekorera alla procedurer längs vägen:
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;};
Låt oss bryta ned denna typ lite:
-
Vi skickar in en
TRPCRouterRecordtill typen som en generic, vilket är en typ som innehåller alla procedurer och underrouter som finns på en tRPC-router. -
Vi itererar över nycklarna i record-objektet, som är procedur- eller router-namn, och gör följande:
- Om nyckeln pekar på en router, anropar vi typen rekursivt på den routerns procedur-record, vilket kommer att dekorera alla procedurer i den routern. Detta ger autofyllning när vi navigerar genom sökvägen.
- Om nyckeln pekar på en procedur, dekorerar vi proceduren med
DecorateProcedure-typen vi skapade tidigare. - Om nyckeln inte pekar på en procedur eller router, tilldelar vi
never-typen vilket i praktiken betyder "denna nyckel existerar inte" och ger ett typfel om vi försöker komma åt den.
🤯 Proxy-omvandlingen
Nu när vi har alla typer på plats måste vi implementera funktionaliteten som omvandlar serverns router-definition på klienten så att vi kan anropa procedurer som vanliga funktioner.
Vi börjar med att skapa en hjälpfunktion för rekursiva proxies - createRecursiveProxy:
Detta är nästan exakt samma implementation som används i produktion, med undantag för att vi inte hanterar vissa edge cases. Se själv!
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 ;}
Det här ser lite magiskt ut, vad gör den egentligen?
-
get-metoden hanterar egenskapsåtkomst som t.ex.post.byId. Nyckeln är egenskapens namn vi kommer åt, så när vi skriverpostblir vårkeypost, och när vi skriverpost.byIdblir vårkeybyId. Den rekursiva proxyn kombinerar alla dessa nycklar till en slutlig sökväg, t.ex. ["post", "byId", "query"], som vi kan använda för att bestämma vilken URL vi ska skicka vår förfrågan till. -
apply-metoden anropas när vi exekverar en funktion på proxyn, som t.ex..query(args).argsär argumenten vi skickar till funktionen, så när vi anroparpost.byId.query(args)kommer vårtargsatt vara vårt input som vi skickar som query-parametrar eller request body beroende på procedurtyp.createRecursiveProxytar emot en callback-funktion som vi mapparapplytill med sökvägen och argumenten.
Här är en visuell representation av hur proxyn fungerar vid anropet trpc.post.byId.query({ id: 1 }):

🧩 Sätt ihop allt
Nu när vi har denna hjälpfunktion och vet vad den gör, låt oss använda den för att skapa vår klient. Vi ger createRecursiveProxy en callback som tar emot sökvägen och argumenten och skickar en förfrågan till servern med fetch. Vi lägger till en generisk typ på funktionen som accepterar vilken tRPC-router som helst (AnyTRPCRouter), och sedan castar vi returtypen till DecorateRouterRecord-typen vi skapade tidigare:
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
Det viktigaste här är att vår sökväg är separerad med . istället för /. Detta låter oss ha en enda API-hanterare på servern som behandlar alla förfrågningar, istället för en per procedur. Om du använder ett ramverk med filbaserad routing som Next.js, känner du igen catchall-filen /api/trpc/[trpc].ts som matchar alla procedursökvägar.
Vi har också en TRPCResponse-typannotering på fetch-förfrågan. Den definierar det JSONRPC-kompatibla svarsformat som servern returnerar. Du kan läsa mer om det här. TL;DR: vi får tillbaka antingen ett result eller ett error-objekt som vi kan använda för att avgöra om förfrågan lyckades eller inte och hantera eventuella fel på lämpligt sätt.
Och det var allt! Detta är all kod du behöver för att anropa dina tRPC-procedurer på klienten som om de vore lokala funktioner. På ytan ser det ut som att vi bara anropar publicProcedure.query / mutation's resolver-funktion via normal egenskapsåtkomst, men i verkligheten korsar vi ett nätverksgräns så att vi kan använda serverbibliotek som Prisma utan att läcka databasuppgifter.
Testa det själv!
Skapa nu klienten och ange din servers URL – du får fullständig autofyllning och typsäkerhet när du anropar dina procedurer!
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' });
Fullständig källkod för klienten finns här, och tester som visar användning här.
Avslutning
Jag hoppas du gillade artikeln och lärde dig något om hur tRPC fungerar. Du bör förmodligen inte använda denna lösning i stället för @trpc/client som bara är några få KB större – den erbjuder mycket mer flexibilitet än vad vi visat här:
-
Frågealternativ för abort-signaler, SSR m.m.
-
Länkar
-
Batchbearbetning av procedurer
-
WebSockets / prenumerationer
-
Bra felhantering
-
Datatransformatorer
-
Hantering av specialfall som när responsen inte följer tRPC-standarden
Vi täckte inte heller serversidan idag, det får kanske bli ett framtida inlägg. Har du frågor är du välkommen att höra av dig till mig på Twitter.
