Creando un cliente tRPC pequeño
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
¿Alguna vez te has preguntado cómo funciona tRPC? ¿Quizás quieres contribuir al proyecto pero te intimidan sus internos? El objetivo de esta publicación es familiarizarte con los detalles internos de tRPC mediante la creación de un cliente mínimo que cubra las partes principales de su funcionamiento.
Se recomienda que comprendas algunos conceptos centrales de TypeScript como genéricos, tipos condicionales, la palabra clave extends y recursión. Si no estás familiarizado con estos, te sugiero revisar el tutorial TypeScript para principiantes de Matt Pocock para familiarizarte con estos conceptos antes de continuar.
Visión general
Supongamos que tenemos un router tRPC simple con tres procedimientos que luce así:
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 ;}),}),});
El objetivo de nuestro cliente es imitar esta estructura de objetos en el lado del cliente para que podamos llamar procedimientos como:
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' });
Para lograr esto, tRPC usa una combinación de objetos Proxy y algo de magia TypeScript para aumentar la estructura de objetos con los métodos .query y .mutate en ellos, ¡lo que significa que en realidad te ENGÑAMOS sobre lo que estás haciendo (más sobre esto después) para brindar una excelente experiencia de desarrollo!
En términos generales, lo que queremos hacer es mapear post.byId.query() a una solicitud GET a nuestro servidor, y post.create.mutate() a una solicitud POST, y los tipos deberían propagarse completamente desde el backend al frontend. Entonces, ¿cómo lo hacemos?
Implementando un pequeño cliente tRPC
🧙♀️ La magia de TypeScript
Comencemos con la divertida magia de TypeScript para desbloquear el increíble autocompletado y seguridad de tipos que todos conocemos y amamos al usar tRPC.
Necesitaremos usar tipos recursivos para poder inferir estructuras de router arbitrariamente profundas. Además, sabemos que queremos que nuestros procedimientos post.byId y post.create tengan los métodos .query y .mutate respectivamente; en tRPC llamamos a esto "decorar" los procedimientos. En @trpc/server, tenemos algunos ayudantes de inferencia que deducirán los tipos de entrada y salida de nuestros procedimientos con estos métodos resueltos, los cuales usaremos para inferir los tipos de estas funciones, ¡así que escribamos código!
Consideremos lo que queremos lograr para proporcionar autocompletado en rutas e inferencia de tipos de entrada y salida de los procedimientos:
-
Si estamos en un router, queremos poder acceder a sus sub-routers y procedimientos (llegaremos a esto en un momento)
-
Si estamos en un procedimiento de query, queremos poder llamar
.queryen él -
Si estamos en un procedimiento de mutación, queremos poder llamar
.mutateen él -
Si intentamos acceder a cualquier otra cosa, queremos obtener un error de tipo que indique que ese procedimiento no existe en el backend
Así que creemos un tipo que haga esto por nosotros:
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;
Usaremos algunos de los ayudantes de inferencia incorporados de tRPC para deducir los tipos de entrada y salida de nuestros procedimientos y definir el tipo Resolver.
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 >>;
Probemos esto en nuestro procedimiento post.byId:
tstypePostById =Resolver <AppRouter ['post']['byId']>;
tstypePostById =Resolver <AppRouter ['post']['byId']>;
¡Genial, eso es lo que esperábamos! Ahora podemos llamar .query en nuestro procedimiento y obtener los tipos de entrada y salida correctos inferidos.
Finalmente, crearemos un tipo que recorrerá recursivamente el router y decorará todos los procedimientos en el camino:
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;};
Analicemos un poco este tipo:
-
Pasamos un
TRPCRouterRecordal tipo como genérico, que es un tipo que contiene todos los procedimientos y sub-routers que existen en un router tRPC. -
Iteramos sobre las claves del registro, que son los nombres de los procedimientos o routers, y hacemos lo siguiente:
- Si la clave apunta a un router, llamamos recursivamente al tipo en el registro de procedimientos de ese router, lo que decorará todos los procedimientos en ese router. Esto proporcionará autocompletado mientras navegamos por la ruta.
- Si la clave apunta a un procedimiento, decoramos el procedimiento usando el tipo
DecorateProcedureque creamos anteriormente. - Si la clave no apunta a un procedimiento ni a un router, asignamos el tipo
never, que equivale a decir "esta clave no existe", lo que generará un error de tipo si intentamos acceder a ella.
🤯 La reasignación mediante Proxy
Ahora que tenemos todos los tipos configurados, necesitamos implementar la funcionalidad que aumentará la definición del router del servidor en el cliente para que podamos invocar los procedimientos como funciones normales.
Primero crearemos una función auxiliar para crear proxies recursivos: createRecursiveProxy:
Esta es casi la misma implementación usada en producción, excepto que no manejamos algunos casos extremos. ¡Compruébalo tú mismo!
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 ;}
Esto parece un poco mágico, ¿qué hace exactamente?
-
El método
getmaneja los accesos a propiedades comopost.byId. La clave es el nombre de la propiedad que estamos accediendo: cuando escribimospost, nuestrakeyserápost, y cuando escribamospost.byId, nuestrakeyserábyId. El proxy recursivo combina todas estas claves en una ruta final, por ejemplo ["post", "byId", "query"], que podemos usar para determinar la URL a la que enviar la solicitud. -
El método
applyse llama cuando invocamos una función en el proxy, como.query(args). Losargsson los argumentos que pasamos a la función, así que cuando llamamospost.byId.query(args), nuestrosargsserán nuestra entrada, que proporcionaremos como parámetros de consulta o cuerpo de la solicitud según el tipo de procedimiento. ElcreateRecursiveProxyrecibe una función de callback que mapearemos conapplyusando la ruta y los argumentos.
A continuación se muestra una representación visual de cómo funciona el proxy en la llamada trpc.post.byId.query({ id: 1 }):

🧩 Integrando todo
Ahora que tenemos este helper y sabemos lo que hace, usémoslo para crear nuestro cliente. Proporcionaremos al createRecursiveProxy un callback que tomará la ruta y los argumentos para solicitar al servidor usando fetch. Necesitaremos agregar un genérico a la función que acepte cualquier tipo de router tRPC (AnyTRPCRouter), y luego convertiremos el tipo de retorno al tipo DecorateRouterRecord que creamos anteriormente:
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
Lo más notable aquí es que nuestra ruta está separada por . en lugar de /. Esto nos permite tener un único manejador de API en el servidor que procesará todas las solicitudes, y no uno para cada procedimiento. Si estás usando un framework con enrutamiento basado en archivos como Next.js, quizás reconozcas el archivo catchall /api/trpc/[trpc].ts que coincidirá con todas las rutas de los procedimientos.
También tenemos una anotación de tipo TRPCResponse en la solicitud fetch. Esto determina el formato de respuesta compatible con JSONRPC que devuelve el servidor. Puedes leer más sobre esto aquí. TL;DR: obtenemos un objeto result o error, que podemos usar para determinar si la solicitud fue exitosa o no, y manejar errores apropiadamente si algo salió mal.
¡Y eso es todo! Este es todo el código que necesitarás para llamar a tus procedimientos tRPC en el cliente como si fueran funciones locales. En la superficie, parece que simplemente estamos llamando a la función resolver de publicProcedure.query / mutation mediante accesos normales a propiedades, pero en realidad estamos cruzando un límite de red para poder usar bibliotecas del lado del servidor como Prisma sin filtrar credenciales de la base de datos.
¡Pruébalo!
Ahora, crea el cliente proporcionando la URL de tu servidor y ¡obtendrás autocompletado completo y seguridad de tipos al llamar a tus procedimientos!
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' });
El código completo del cliente está disponible aquí, y las pruebas de uso aquí.
Conclusión
Espero que hayas disfrutado este artículo y aprendido cómo funciona tRPC internamente. Probablemente no deberías usar este cliente minimalista en lugar de @trpc/client, que solo pesa unos pocos KB más pero ofrece mucha más flexibilidad:
-
Opciones de query para señales de aborto, SSR, etc...
-
Links
-
Agrupación de procedimientos (batching)
-
Soporte para WebSockets / suscripciones
-
Manejo elegante de errores
-
Transformadores de datos
-
Manejo de casos extremos como respuestas no compatibles con tRPC
Tampoco cubrimos hoy el lado del servidor, quizás lo abordemos en un futuro artículo. Si tienes preguntas, no dudes en contactarme por Twitter.
