tRPC: ¿vale la pena dejar REST?
Qué es tRPC, cómo funciona, y por qué lo uso en side projects pero no en la pega.
El problema que resuelve
Si trabajas con TypeScript en el frontend y en el backend, seguro has vivido esto: defines una ruta en Express, escribes los tipos del response, después vas al cliente y vuelves a escribir los mismos tipos — o peor, usas any y rezas. Cambias un campo en la API y el frontend se entera en runtime, cuando el usuario ya vio un error.
tRPC existe para eliminar esa fricción. Es una librería que te permite llamar funciones del backend directamente desde el frontend, con tipado end-to-end, sin generar código, sin escribir schemas OpenAPI, sin duplicar tipos. La primera vez que lo ves parece truco, pero no hay nada escondido: es inferencia de tipos de TypeScript llevada al extremo.
¿Cómo funciona?
tRPC no genera código ni usa code-gen. Lo que hace es compartir los tipos de TypeScript entre el servidor y el cliente a través de la inferencia de tipos. Tú defines tus procedimientos en el backend y exportas el tipo del router — el cliente importa ese tipo y TypeScript se encarga del resto.
El servidor
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
// Query — equivalente a un GET
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user; // el tipo se infiere automáticamente
}),
// Mutation — equivalente a un POST/PUT
updateUser: t.procedure
.input(z.object({
id: z.string(),
name: z.string().min(1),
}))
.mutation(async ({ input }) => {
return db.user.update({
where: { id: input.id },
data: { name: input.name },
});
}),
});
// Esto es lo que el cliente importa — solo el TIPO, no el código
export type AppRouter = typeof appRouter;
El cliente
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/trpc';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
});
// Autocompletado completo — sabe que getUser recibe { id: string }
// y retorna el tipo exacto que devuelve tu query
const user = await trpc.getUser.query({ id: '123' });
console.log(user.name); // ✅ tipado, sin cast, sin any
// Si cambias el schema en el servidor, esto da error en compile time
const user2 = await trpc.getUser.query({ id: 123 }); // ❌ Error: id debe ser string
Eso es todo. No hay archivo .yaml de OpenAPI, no hay codegen corriendo en un script aparte. Cambias el backend, guardas, y el frontend te avisa inmediatamente si algo se rompió.
Donde me funcionó: side projects con Next.js
He usado tRPC en side projects con Next.js y la experiencia de desarrollo es adictiva. Cambias algo en el backend, vuelves al componente de React, y el error aparece antes de que guardes el archivo. No hay un momento de “¿qué shape tiene este response?” — TypeScript ya lo sabe.
Lo que tenían en común esos proyectos no era el tamaño ni el framework, sino tres condiciones:
- Todo vivía en el mismo repo y en TypeScript. Esta es la condición no negociable. El cliente importa
typeof appRouter; si no puede importarlo, tRPC no tiene de dónde sacar los tipos. - Yo tocaba el front y el back. No había que negociar un contrato con nadie — el contrato era la función.
- Nadie más consumía la API. Ni una app móvil, ni un partner, ni otro equipo.
Con esas tres, iterar se siente como llamar funciones locales. Cambias la firma, el tipo se propaga, sigues.
Donde no lo usaría: la pega
En la pega el escenario es el opuesto, y ahí REST con buena documentación sigue siendo la opción correcta:
- Hay apps móviles consumiendo las mismas APIs. Un cliente en Swift o Kotlin no puede importar un tipo de TypeScript. Necesita HTTP estándar y un contrato que pueda leer.
- Front y back son equipos distintos. Cuando la coordinación pasa por un contrato, un OpenAPI explícito sirve de documentación y de acuerdo formal. Con tRPC ese contrato queda implícito en el código de otro equipo.
- No todo el backend es TypeScript. Si algún servicio está en Go o Python, el tipado end-to-end se corta ahí.
Hay un punto más que se olvida: caching HTTP. Un GET /users/123 lo entienden los CDNs, los proxies y el browser sin configurar nada. tRPC puede cachear, pero pierdes esa semántica que la infraestructura ya conoce.
tRPC vs REST vs GraphQL — en corto
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Tipado E2E | Manual o con codegen | Con codegen | Automático (por inferencia) |
| Clientes externos | Cualquier lenguaje | Cualquier lenguaje | Solo TypeScript |
| Setup | Bajo | Medio-alto | Muy bajo |
| Caching HTTP | Nativo | Difícil (todo es POST) | Posible, no nativo |
| Documentación | OpenAPI/Swagger | Schema introspectable | No estándar |
Conclusión
tRPC resuelve un problema real — la desincronización de tipos entre frontend y backend — pero a cambio te pide que ambos lados sean TypeScript y vivan cerca. No es un reemplazo de REST; es una forma de no necesitar una API “formal” cuando nadie de afuera la va a consumir.
Si arrancas un proyecto full-stack en TypeScript y eres tú (o tu equipo) de punta a punta, dale una oportunidad: la velocidad se nota desde el primer endpoint. Si tu API es un contrato con otros, REST sigue siendo el rey — y no tiene nada de malo.