Detectando la desviación del contrato de API en tiempo de compilación con un despliegue incremental de tRPC
tRPC convierte un campo del backend con nombre modificado en un error de compilación de TypeScript. Cómo implementarlo endpoint por endpoint junto a REST, y en qué casos es la herramienta inadecuada.
Fuentes: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**
Picture a lending platform where a backend engineer renames applicantName to applicantFullName in the loan application response. The API, the backend tests and the pull request are all updated and approved, yet two days later the loan summary screen quietly renders blank names: no exception, no console warning, just an empty string, because the frontend still reads a key that no longer exists. Both sides were written in TypeScript, and none of that type information crossed the network boundary. Below you will see how tRPC closes that gap, how to adopt it gradually beside REST, and when it does not fit.
What tRPC is, and what it is not
tRPC no es un protocolo de transmisión y no reemplaza a REST o GraphQL en el nivel de transporte. Las solicitudes siguen siendo llamadas HTTP normales. Lo que añade es una capa de tipos compartidos: los tipos de retorno de las proceduras del servidor se infieren mediante TypeScript y llegan directamente al cliente, sin ningún archivo de esquema ni paso de generación de código intermedio.
Un modelo mental útil es pensar en una llamada a función que se ejecuta en el servidor. El contrato entre quien llama y quien es llamado es lo que indique el compilador de TypeScript, no un documento de especificación separado que pueda haberse actualizado o no. Cuando el servidor cambia lo que devuelve una procedura, todos los puntos de llamada del cliente que dependan de la estructura anterior dejarán de compilar. El error se manifiesta en el editor en la línea exacta donde falta ese campo, en lugar de días después en producción.
El costo de una capa API sin tipado
En el escenario anterior, la capa de servicio frontend se veía más o menos así: cada método obtenía JSON y lo devolvía con el tipo any, y los componentes leían los campos del resultado confiando en ello.
// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't
El equipo contó 47 métodos con un tipo de retorno any. Cada uno representaba una situación en la que renombrar un campo, reestructurar un objeto anidado o cambiar un valor de enum permitiría una compilación correcta, pero el problema solo surgiría en tiempo de ejecución, cuando un usuario verificaba el estado de una solicitud. En un producto financiero, esto no es solo un error cosmético: mostrar datos incorrectos o faltantes a un cliente puede convertirse en un problema de cumplimiento.
Migración con el patrón strangler fig
Escribir de nuevo cada endpoint al mismo tiempo es arriesgado. La opción más segura es el enfoque del ficus estrangulador: dejar los endpoints REST existentes sin tocar, integrar tRPC junto a ellos y migrar un endpoint a la vez. Cada endpoint migrado obtiene seguridad de tipos de inmediato, y nada de lo que aún no se haya migrado necesita cambiar.
Definir el router
El lado del servidor comienza con initTRPC.create(), que devuelve un objeto constructor denominado convencionalmente t. Los enrutadores pueden ser anidados, por lo que un subenrutador loans agrupa los procedimientos relacionados con los préstamos. Observe tres aspectos en este fragmento. Primero, cada procedimiento declara sus entradas mediante un esquema Zod, de modo que un ID mal formado o un estado desconocido son rechazados en tiempo de ejecución antes de que se ejecute el manejador, y el mismo esquema proporciona al manejador una variable input de tipo definido. Segundo, query se utiliza para las lecturas y mutation para las escrituras. Tercero, nadie escribe manualmente el tipo de retorno: TypeScript lo infiere a partir de lo que devuelve la llamada a la base de datos. La última línea exporta únicamente el tipo del enrutador, que es lo que consume el cliente.
// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;
Un detalle que hay que corregir si copias esto: el manejador lanza TRPCError, que también debe importarse desde @trpc/server. Falta en la línea de importación aquí.
Creación del cliente
En el frontend, el cliente se construye a partir del tipo AppRouter. Dado que la importación utiliza import type, no hay código del servidor en el paquete del navegador; solo se comparte la información de tipo. httpBatchLink combina varias llamadas realizadas en el mismo intervalo de tiempo en una sola solicitud HTTP, y su callback headers se ejecuta por cada solicitud, lo cual es un lugar conveniente para adjuntar un token de acceso.
// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});
El nombre de la función refleja la versión de tRPC en uso: createTRPCProxyClient corresponde a la API v10, mientras que las versiones más recientes la denominan createTRPCClient. Consulte la documentación actual correspondiente a la versión que haya instalado. Compartir el tipo también implica que el cliente pueda importarlo, generalmente a través de un monorepo o de un paquete de espacio de trabajo compartido.
Llamada a un procedimiento
Una llamada ahora se parece a un método en un objeto local. El resultado está completamente tipado, por lo que hacer referencia a un campo que el servidor ya no devuelve genera un error de compilación en lugar de un valor undefined silencioso.
// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error
Respuesta a las objeciones del equipo
Esperar enfrentarse a resistencia. Suelen surgir tres objeciones, y cada una merece una respuesta directa.
- ¿Por qué no OpenAPI con generación de código? Es la objeción más fuerte, ya que los clientes generados sí proporcionan tipos. El costo es un esquema que debe mantenerse, un paso de generación que debe ejecutarse, archivos generados que deben guardarse y la disciplina necesaria para mantener todo sincronizado. Con tRPC, el propio código del servidor es la única fuente de verdad. Si prefiere el enfoque OpenAPI, consulte cómo generar un cliente de Next.js con tipos a partir de NestJS Swagger.
- ¿Qué pasa con los consumidores que no están escritos en TypeScript? En este caso, algunas herramientas internas de Python se encargan de la API. Sus puntos finales siguen utilizando el formato REST. La migración incremental lo hace sencillo, ya que ambos estilos coexisten junto a los demás.
Cuando los beneficios se vuelven evidentes
Aproximadamente seis semanas después, la máquina de estados para las solicitudes de préstamo necesitó un cambio estructural. Un campo de estado plano se convirtió en un objeto anidado que registra tanto la etapa de revisión como el resultado de la aprobación:
// Before
type LoanApplication = {
status: 'PENDING' | 'APPROVED' | 'REJECTED';
}
// After
type LoanApplication = {
applicationStatus: {
stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
};
}
Bajo el antiguo sistema, cada componente que leía application.status seguiría compilándose sin problemas pero fallaría en tiempo de ejecución, y la única solución habría sido realizar una búsqueda en toda la base de código seguida de esperar a que se encontrara el problema. Con tRPC, al cambiar el tipo de retorno del procedimiento se identificaron de inmediato 8 puntos donde se realizaban llamadas desde el frontend. Todos ellos se corrigieron en una tarde, y un cambio que afectaba a varios sistemas se lanzó sin errores en tiempo de ejecución. Después de eso, la conversación pasó de cuestionar la migración a preguntar qué endpoint se movería a continuación.
Resultados después de seis meses
- Los tipos de retorno
anyen la capa API disminuyeron de 47 a 3. - Los 3 restantes son cargas de trabajo de webhook de terceros sin tipos definidos; se analizan mediante esquemas Zod validados en lugar de confiarse en ellos ciegamente.
Los 44 tipos any eliminados representan errores latentes que nunca tuvieron la oportunidad de manifestarse. Para conocer patrones que permitan eliminar los últimos restos, consulte seis alternativas seguras desde el punto de vista tipológico para any.
Cuándo tRPC no es adecuado
tRPC solo resulta útil cuando TypeScript está presente en ambos extremos de la conexión. Un backend poliglota o una API pública consumida por terceros requiere un contrato explícito e independiente del lenguaje, como OpenAPI o GraphQL.
Tenga también en cuenta que la garantía se aplica en tiempo de compilación para los resultados. Zod valida las entradas en tiempo de ejecución, pero el tipo de retorno inferido no verifica nada en ese momento, y devolver un registro de base de datos sin procesar, como lo hace el router anterior, envía todas las columnas al cliente. Para dominios sensibles, seleccione o asigne solo los campos que realmente pretende exponer.
Puntos clave
- Tener TypeScript en ambos lados no es lo mismo que contar con un contrato tipado entre ellos; tRPC proporciona ese vínculo faltante sin necesidad de esquemas ni pasos de generación de código.
- Migre endpoint por endpoint junto con REST, de modo que los consumidores que no usan TypeScript sigan funcionando y cada paso sea lo suficientemente pequeño como para revisarlo.
- La mayor ventaja se observa durante las refactorizaciones, cuando el compilador enumera todos los puntos de llamada afectados para usted.