Type-safe API com tRPC

1. Introdução ao tRPC e o Problema da Tipagem em APIs

Manter a consistência de tipos entre frontend e backend é um dos maiores desafios no desenvolvimento web moderno. Em projetos tradicionais, você define schemas no backend, depois os reescreve no frontend, e qualquer alteração exige sincronização manual — um processo propenso a erros que quebra aplicações em produção.

tRPC (TypeScript Remote Procedure Call) surge como uma alternativa radical: ele permite que você defina procedimentos no backend e os consuma no frontend com inferência total de tipos, sem geradores de código, schemas duplicados ou contratos externos.

Comparado a outras abordagens:
- REST + Zod: Você ainda precisa gerenciar endpoints e schemas separadamente, mesmo com validação.
- GraphQL: Oferece tipagem forte, mas exige schemas GraphQL, resolvers e ferramentas como GraphQL Code Generator.
- OpenAPI: Gera tipos, mas a manutenção do schema YAML/JSON é manual e frequentemente dessincronizada.

tRPC elimina essas camadas intermediárias, aproveitando o TypeScript como única fonte de verdade.

2. Arquitetura Fundamental do tRPC

O núcleo do tRPC é baseado em procedimentos — funções que podem ser queries (leitura), mutations (escrita) ou subscriptions (tempo real). Esses procedimentos são organizados em routers, que formam uma árvore tipada.

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

const t = initTRPC.create();

const appRouter = t.router({
  hello: t.procedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello, ${input.name}!` };
    }),
});

O contexto (createContext) permite injetar dependências tipadas (como conexão com banco de dados ou usuário autenticado) em todos os procedimentos.

export const createContext = async () => {
  const user = await getUserFromRequest();
  return { user };
};

const t = initTRPC.context<typeof createContext>().create();

3. Definindo Procedimentos com Inferência de Tipos

A validação de entrada com Zod não apenas garante segurança em runtime, mas também fornece inferência automática de tipos para o TypeScript.

import { z } from 'zod';

const userSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().min(0).max(150),
});

const userRouter = t.router({
  create: t.procedure
    .input(userSchema.omit({ id: true }))
    .mutation(async ({ input, ctx }) => {
      // input é tipado automaticamente como { email: string; age: number }
      const user = await ctx.db.user.create({ data: input });
      return user; // O tipo de retorno é inferido e propagado para o cliente
    }),

  getById: t.procedure
    .input(z.string().uuid())
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findUnique({ where: { id: input } });
    }),
});

O TypeScript propaga esses tipos automaticamente para o cliente — sem declarações manuais, sem as casts, sem interface duplicadas.

4. Consumo Tipado no Frontend com @trpc/client

No frontend, a configuração é mínima e a inferência é total:

import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';

export const trpc = createTRPCReact<AppRouter>();

O uso em componentes React é direto e totalmente tipado:

function UserProfile({ userId }: { userId: string }) {
  // Tipagem automática de parâmetros e resposta
  const { data, isLoading } = trpc.user.getById.useQuery(userId);

  const createUser = trpc.user.create.useMutation({
    onSuccess: (newUser) => {
      // newUser é tipado automaticamente como o retorno da mutation
      console.log(newUser.email);
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h1>{data?.email}</h1>
      <button onClick={() => createUser.mutate({ email: 'test@test.com', age: 25 })}>
        Create User
      </button>
    </div>
  );
}

Se você alterar o schema no backend, o TypeScript apontará todos os locais no frontend que precisam ser atualizados — em tempo de compilação.

5. Tratamento de Erros e Respostas Complexas

tRPC fornece erros tipados com TRPCError:

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

const userRouter = t.router({
  getById: t.procedure
    .input(z.string().uuid())
    .query(async ({ input, ctx }) => {
      const user = await ctx.db.user.findUnique({ where: { id: input } });
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found',
        });
      }
      return user;
    }),
});

Para serialização de tipos complexos como Date, Map ou Set, utilize superjson:

import superjson from 'superjson';

const t = initTRPC.create({
  transformer: superjson,
});

// Agora você pode retornar Date diretamente
const eventRouter = t.router({
  getEvents: t.procedure.query(async () => {
    return [
      { title: 'Meeting', date: new Date('2024-01-15') },
    ];
  }),
});

6. Autenticação e Autorização com Types

Middlewares no tRPC podem modificar o contexto e adicionar informações tipadas:

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user, // Agora tipado como não-nulo
    },
  });
});

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

const protectedRouter = t.router({
  getProfile: protectedProcedure.query(({ ctx }) => {
    // ctx.user é garantido como não-nulo pelo middleware
    return ctx.user;
  }),
});

Isso permite expor informações do usuário no contexto sem quebrar a segurança de tipos — o middleware garante que procedimentos protegidos sempre tenham ctx.user disponível.

7. Boas Práticas e Padrões Avançados

Organize routers em módulos e faça merge:

// routers/user.ts
export const userRouter = t.router({ ... });

// routers/post.ts
export const postRouter = t.router({ ... });

// routers/index.ts
export const appRouter = t.router({
  user: userRouter,
  post: postRouter,
});

export type AppRouter = typeof appRouter;

Crie pipelines de validação reutilizáveis:

const validatePagination = t.middleware(({ input, next }) => {
  const { page, limit } = input as { page?: number; limit?: number };
  return next({
    input: { ...input, page: page ?? 1, limit: limit ?? 10 },
  });
});

const paginatedProcedure = t.procedure.use(validatePagination);

Para code splitting no servidor, utilize lazy loading de routers:

const appRouter = t.router({
  admin: lazy(() => import('./routers/admin').then(m => m.adminRouter)),
});

8. Testes, Monitoramento e Deploy

Testes unitários com procedimentos tipados são diretos:

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

const createCaller = createCallerFactory(appRouter);

test('create user', async () => {
  const caller = createCaller({ user: { id: '1', role: 'ADMIN' } });
  const result = await caller.user.create({ email: 'test@test.com', age: 30 });
  expect(result.email).toBe('test@test.com');
});

Para monitoramento, utilize hooks do tRPC:

const t = initTRPC.create();
t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`Procedure ${path} (${type}) took ${duration}ms`);
  return result;
});

Para deploy, escolha o adapter adequado:

// Next.js
export default trpcNext.createNextApiHandler({ router: appRouter, createContext });

// Express
import * as trpcExpress from '@trpc/server/adapters/express';
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter, createContext }));

// Fastify
import * as trpcFastify from '@trpc/server/adapters/fastify';
fastify.register(trpcFastify.fastifyTRPCPlugin, { router: appRouter, createContext });

tRPC transforma a maneira como construímos APIs full-stack com TypeScript. Ao eliminar a duplicação de tipos e fornecer inferência automática, ele reduz bugs, acelera o desenvolvimento e torna a refatoração segura. Se você já investe em TypeScript no frontend e backend, tRPC é o próximo passo lógico para uma experiência de desenvolvimento verdadeiramente integrada.

Referências