Saltar al contenido principal

Creando un cliente tRPC pequeño

· 11 min de lectura
Julius Marminge
tRPC Core Team Member
Traducción Beta No Oficial

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.

información

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

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;
}),
}),
});

El objetivo de nuestro cliente es imitar esta estructura de objetos en el lado del cliente para que podamos llamar procedimientos como:

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' });

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 .query en él

  • Si estamos en un procedimiento de mutación, queremos poder llamar .mutate en é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:

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;

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.

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

Probemos esto en nuestro procedimiento post.byId:

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>

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

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

Analicemos un poco este tipo:

  1. Pasamos un TRPCRouterRecord al tipo como genérico, que es un tipo que contiene todos los procedimientos y sub-routers que existen en un router tRPC.

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

información

Esta es casi la misma implementación usada en producción, excepto que no manejamos algunos casos extremos. ¡Compruébalo tú mismo!

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

Esto parece un poco mágico, ¿qué hace exactamente?

  • El método get maneja los accesos a propiedades como post.byId. La clave es el nombre de la propiedad que estamos accediendo: cuando escribimos post, nuestra key será post, y cuando escribamos post.byId, nuestra key será 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 apply se llama cuando invocamos una función en el proxy, como .query(args). Los args son los argumentos que pasamos a la función, así que cuando llamamos post.byId.query(args), nuestros args serán nuestra entrada, que proporcionaremos como parámetros de consulta o cuerpo de la solicitud según el tipo de procedimiento. El createRecursiveProxy recibe una función de callback que mapearemos con apply usando 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 }):

proxy

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

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

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!

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

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.