Hoppa till huvudinnehållet

Bygga en minimal tRPC-klient

· 11 minuters läsning
Julius Marminge
tRPC Core Team Member
Inofficiell Beta-översättning

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.

info

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:

ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});
ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});

Målet med vår klient är att efterlikna denna objektstruktur på klientsidan så att vi kan anropa procedurer så här:

ts
const 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' });
ts
const 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 .query på den

  • Om vi befinner oss på en mutation-procedure vill vi kunna anropa .mutate på 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:

ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;
ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
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.

ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 
ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 

Låt oss testa detta på vår post.byId-procedure:

ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>
ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>

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:

ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};
ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};

Låt oss bryta ned denna typ lite:

  1. Vi skickar in en TRPCRouterRecord till typen som en generic, vilket är en typ som innehåller alla procedurer och underrouter som finns på en tRPC-router.

  2. 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:

info

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!

ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}
ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}

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 skriver post blir vår key post, och när vi skriver post.byId blir vår key byId. 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 anropar post.byId.query(args) kommer vårt args att vara vårt input som vi skickar som query-parametrar eller request body beroende på procedurtyp. createRecursiveProxy tar emot en callback-funktion som vi mappar apply till med sökvägen och argumenten.

Här är en visuell representation av hur proxyn fungerar vid anropet trpc.post.byId.query({ id: 1 }):

proxy

🧩 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:

ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with
ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<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!

ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }
ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }

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.