tRPC con TypeScript: APIs end-to-end tipadas sin schema, router y cliente inferido

tRPC resuelve el problema más tedioso de las APIs REST con TypeScript: mantener sincronizados los tipos del servidor y del cliente. En lugar de generar código a partir de un schema OpenAPI o escribir interfaces duplicadas, tRPC exporta el tipo del router directamente y el cliente lo importa para obtener autocompletado y comprobación de errores en tiempo de compilación.

Crear el router en el servidor

La configuración básica requiere inicializar tRPC y definir el router con sus procedimientos:

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

// server/routers/usuario.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

const usuarioSchema = z.object({
  id: z.number(),
  nombre: z.string(),
  email: z.string().email(),
});

export const usuarioRouter = router({
  obtenerTodos: publicProcedure
    .output(z.array(usuarioSchema))
    .query(async () => {
      return db.usuario.findMany();
    }),

  obtenerPorId: publicProcedure
    .input(z.object({ id: z.number() }))
    .output(usuarioSchema)
    .query(async ({ input }) => {
      return db.usuario.findUniqueOrThrow({ where: { id: input.id } });
    }),

  crear: publicProcedure
    .input(z.object({ nombre: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return db.usuario.create({ data: input });
    }),
});

Router raíz y exportar el tipo AppRouter

El router raíz combina todos los sub-routers. El tipo AppRouter se exporta y el cliente lo importa sin importar ningún código del servidor:

// server/routers/index.ts
import { router } from '../trpc';
import { usuarioRouter } from './usuario';
import { productoRouter } from './producto';

export const appRouter = router({
  usuario: usuarioRouter,
  producto: productoRouter,
});

// SOLO el tipo, no la implementación:
export type AppRouter = typeof appRouter;

Integración con Next.js (App Router)

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

Cliente tipado con el tipo del servidor

El cliente solo importa el tipo AppRouter. En tiempo de compilación, TypeScript conoce todos los procedimientos, sus inputs y sus outputs:

// utils/trpc.ts (cliente Next.js)
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';

export const trpc = createTRPCReact<AppRouter>();

// Componente React tipado end-to-end:
function ListaUsuarios() {
  // TypeScript sabe que data es Usuario[] | undefined:
  const { data, isLoading } = trpc.usuario.obtenerTodos.useQuery();

  const crearUsuario = trpc.usuario.crear.useMutation({
    onSuccess: (nuevoUsuario) => {
      // nuevoUsuario está completamente tipado
      console.log(nuevoUsuario.nombre);
    },
  });

  return (
    <ul>
      {data?.map((u) => (
        <li key={u.id}>{u.nombre}</li>
      ))}
    </ul>
  );
}

Middleware tipado

Los middlewares de tRPC pueden añadir propiedades al contexto y restringir el tipo del procedimiento resultante:

// Middleware de autenticación:
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.usuario) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  // ctx.session.usuario es definido a partir de aquí:
  return next({ ctx: { ...ctx, usuario: ctx.session.usuario } });
});

export const protectedProcedure = t.procedure.use(isAuthed);

// Ahora ctx.usuario está tipado en los procedimientos protegidos:
const perfilRouter = router({
  obtenerPerfil: protectedProcedure.query(({ ctx }) => {
    return db.usuario.findUnique({ where: { id: ctx.usuario.id } });
  }),
});

Subscriptions con WebSocket

import { observable } from '@trpc/server/observable';

const mensajesRouter = router({
  onNuevoMensaje: publicProcedure
    .input(z.object({ sala: z.string() }))
    .subscription(({ input }) => {
      return observable<Mensaje>((emit) => {
        const unsubscribe = eventosMensajes.on(input.sala, (msg) => {
          emit.next(msg);
        });
        return unsubscribe;
      });
    }),
});

Imagen: Pexels / Tima Miroshnichenko

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP