Como Usar tRPC: APIs End-to-End Tipadas sem Geração de Código em Produção Já leu

O Problema das APIs Tradicionais Quando desenvolvemos aplicações modernas com TypeScript tanto no frontend quanto no backend, enfrentamos um desafio recorrente: manter a tipagem sincronizada entre cliente e servidor. As abordagens tradicionais (REST com Swagger, GraphQL com geração de código) exigem um passo intermediário de geração de tipos ou documentação manual que frequentemente fica desatualizada. Imagine uma situação comum: você altera um endpoint no backend, adicionando um novo campo a uma resposta. O frontend continua usando os tipos antigos até que alguém manualmente atualize o arquivo de tipos gerado. Este descompasso é a raiz de muitos bugs em produção. O tRPC resolve isso de forma elegante: em vez de serializar dados em JSON e perder informações de tipo no caminho, ele transmite apenas os dados essenciais enquanto mantém a tipagem TypeScript pura em ambos os lados. Entendendo o tRPC O Conceito Fundamental tRPC significa "TypeScript Remote Procedure Call" — é um framework que permite chamar funções do servidor diretamente do

O Problema das APIs Tradicionais

Quando desenvolvemos aplicações modernas com TypeScript tanto no frontend quanto no backend, enfrentamos um desafio recorrente: manter a tipagem sincronizada entre cliente e servidor. As abordagens tradicionais (REST com Swagger, GraphQL com geração de código) exigem um passo intermediário de geração de tipos ou documentação manual que frequentemente fica desatualizada.

Imagine uma situação comum: você altera um endpoint no backend, adicionando um novo campo a uma resposta. O frontend continua usando os tipos antigos até que alguém manualmente atualize o arquivo de tipos gerado. Este descompasso é a raiz de muitos bugs em produção. O tRPC resolve isso de forma elegante: em vez de serializar dados em JSON e perder informações de tipo no caminho, ele transmite apenas os dados essenciais enquanto mantém a tipagem TypeScript pura em ambos os lados.

Entendendo o tRPC

O Conceito Fundamental

tRPC significa "TypeScript Remote Procedure Call" — é um framework que permite chamar funções do servidor diretamente do cliente com segurança de tipo total, sem necessidade de gerar código intermediário. Diferentemente de REST ou GraphQL, tRPC explora a natureza do TypeScript para oferecer type-safety automática.

A magia acontece porque tanto o cliente quanto o servidor compartilham a mesma definição de tipos. Quando você define um procedimento no servidor usando tRPC, você está simultaneamente criando a interface que o cliente usará. Não há arquivo de esquema separado, não há geração de código — apenas TypeScript puro.

// backend/router.ts
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router({
  user: t.procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({
        where: { id: input.id }
      });
      return {
        id: user.id,
        name: user.name,
        email: user.email
      };
    }),
});

export type AppRouter = typeof router;

Por Que Funciona Sem Geração de Código

A resposta está no sistema de tipos do TypeScript. Quando você exporta AppRouter, você está exportando os tipos da sua API. No frontend, você importa esse tipo e usa para criar um cliente tipado:

// frontend/client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../backend/router';

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
    }),
  ],
});

// Agora qualquer chamada é totalmente tipada
const user = await trpc.user.query({ id: '123' });
// TypeScript sabe que 'user' tem as propriedades: id, name, email

Aqui está o ponto crítico: o tipo AppRouter é apenas informação de tempo de compilação. Ele não gera nenhum código JavaScript extra. O cliente sabe exatamente qual forma de dados esperar porque tem acesso aos mesmos tipos que o servidor usou ao definir os procedimentos.

Construindo uma API Completa com tRPC

Estrutura de um Servidor tRPC

Um servidor tRPC é construído em cima de um framework HTTP (Express, Next.js, etc.) e expõe um router com procedimentos. Existem três tipos de procedimentos: queries (leitura), mutations (escrita) e subscriptions (tempo real).

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

const t = initTRPC.create();

interface User {
  id: string;
  name: string;
  email: string;
}

const users: User[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com' },
  { id: '2', name: 'Bob', email: 'bob@example.com' },
];

export const appRouter = t.router({
  // Query: buscar usuário
  getUser: t.procedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      return users.find(u => u.id === input.id);
    }),

  // Query: listar todos os usuários
  listUsers: t.procedure
    .query(() => {
      return users;
    }),

  // Mutation: criar usuário
  createUser: t.procedure
    .input(z.object({
      name: z.string(),
      email: z.string().email(),
    }))
    .mutation(({ input }) => {
      const newUser: User = {
        id: String(users.length + 1),
        ...input,
      };
      users.push(newUser);
      return newUser;
    }),

  // Mutation: atualizar usuário
  updateUser: t.procedure
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      email: z.string().email().optional(),
    }))
    .mutation(({ input }) => {
      const user = users.find(u => u.id === input.id);
      if (!user) throw new Error('Usuário não encontrado');

      if (input.name) user.name = input.name;
      if (input.email) user.email = input.email;

      return user;
    }),
});

export type AppRouter = typeof appRouter;

// Criar servidor HTTP
const server = createHTTPServer({
  router: appRouter,
});

server.listen(3000, () => {
  console.log('tRPC server running on http://localhost:3000');
});

Note que usamos zod para validação de entrada. Isso garante que os dados que chegam do cliente correspondem ao esperado, e o TypeScript infere automaticamente os tipos de entrada e saída.

Cliente Consumindo a API

No lado do cliente, temos acesso a todos os procedimentos com autocompletar automático:

// client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
    }),
  ],
});

async function main() {
  // Buscar um usuário
  const user = await trpc.getUser.query({ id: '1' });
  console.log(user); // { id: '1', name: 'Alice', email: 'alice@example.com' }

  // Listar todos
  const allUsers = await trpc.listUsers.query();
  console.log(allUsers);

  // Criar novo usuário
  const newUser = await trpc.createUser.mutation({
    name: 'Charlie',
    email: 'charlie@example.com',
  });
  console.log(newUser);

  // Atualizar usuário
  const updated = await trpc.updateUser.mutation({
    id: '1',
    name: 'Alice Updated',
  });
  console.log(updated);
}

main();

Se você tentar passar dados inválidos ou acessar um procedimento que não existe, o TypeScript reclamará imediatamente:

// ❌ Erro de tipagem — 'id' deve ser string, não number
await trpc.getUser.query({ id: 123 });

// ❌ Erro de tipagem — 'nonExistent' não existe no router
await trpc.nonExistent.query();

// ✅ Correto — TypeScript permite e valida tipos
await trpc.getUser.query({ id: '1' });

Padrões Avançados e Boas Práticas

Reutilizando Lógica com Middlewares

tRPC oferece middlewares que executam antes de cada procedimento, permitindo adicionar autenticação, logging ou autorização:

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

const t = initTRPC
  .context<{ userId?: string }>()
  .create();

// Middleware de autenticação
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      userId: ctx.userId,
    },
  });
});

// Criar procedures protegidas
const protectedProcedure = t.procedure.use(isAuthenticated);

export const appRouter = t.router({
  // Procedure pública
  getPublicData: t.procedure
    .query(() => ({ message: 'Dados públicos' })),

  // Procedure protegida
  getPrivateData: protectedProcedure
    .query(({ ctx }) => {
      return { message: `Dados privados do usuário ${ctx.userId}` };
    }),

  // Mutation protegida
  updateProfile: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(({ ctx, input }) => {
      return { userId: ctx.userId, name: input.name };
    }),
});

Composição de Routers

Em aplicações maiores, é comum dividir os procedimentos em múltiplos routers. tRPC permite compor routers facilmente:

// users.ts
const usersRouter = t.router({
  list: t.procedure.query(() => users),
  getById: t.procedure
    .input(z.string())
    .query(({ input }) => users.find(u => u.id === input)),
});

// posts.ts
const postsRouter = t.router({
  list: t.procedure.query(() => posts),
  create: t.procedure
    .input(z.object({ title: z.string(), userId: z.string() }))
    .mutation(({ input }) => {
      const post = { id: generateId(), ...input };
      posts.push(post);
      return post;
    }),
});

// router.ts (composição)
export const appRouter = t.router({
  user: usersRouter,
  post: postsRouter,
});

No cliente, a estrutura se reflete automaticamente:

// Acesso aos procedures via namespaces
await trpc.user.list.query();
await trpc.user.getById.query('123');
await trpc.post.create.mutation({ title: 'Hello', userId: '1' });

Tratamento de Erros

tRPC oferece uma forma estruturada de lidar com erros com códigos específicos:

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

const appRouter = t.router({
  deleteUser: t.procedure
    .input(z.string())
    .mutation(({ input }) => {
      const user = users.find(u => u.id === input);

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Usuário não encontrado',
        });
      }

      if (user.isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Não é possível deletar um admin',
        });
      }

      const index = users.indexOf(user);
      users.splice(index, 1);
      return { success: true };
    }),
});

// No cliente, você pode tratar erros assim:
try {
  await trpc.deleteUser.mutation('123');
} catch (error) {
  if (error.code === 'NOT_FOUND') {
    console.log('Usuário não existe');
  } else if (error.code === 'FORBIDDEN') {
    console.log('Não tem permissão');
  }
}

Comparação com Alternativas

REST vs tRPC

REST tradicional exige que você defina endpoints e tipos separadamente. Se mudar um endpoint, precisa atualizar documentação e gerar novos tipos no cliente:

// REST (sem type-safety)
const response = await fetch('/api/users/123');
const user = await response.json();
// TypeScript não sabe qual é a forma de 'user'
console.log(user.name); // Pode quebrar em runtime

// tRPC (com type-safety)
const user = await trpc.getUser.query({ id: '123' });
console.log(user.name); // TypeScript garante que existe

GraphQL vs tRPC

GraphQL oferece um modelo declarativo poderoso, mas exige geração de código ou runtime introspection. tRPC é mais simples para aplicações TypeScript monorepo:

// GraphQL requer query string e geração de tipos
const query = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;
const result = await client.query({ query, variables: { id: '123' } });

// tRPC é apenas uma chamada de função tipada
const user = await trpc.getUser.query({ id: '123' });

tRPC não é melhor que GraphQL — é uma alternativa mais leve quando você controla ambos os lados da comunicação (monorepo TypeScript).

Conclusão

Aprendemos que tRPC elimina o gap de tipagem entre cliente e servidor sem adicionar passos de geração de código, porque explore o sistema de tipos do TypeScript de forma criativa. Você define os tipos uma vez no servidor, exporta-os, e o cliente os importa — simples, mas poderoso.

Também vimos que tRPC é ideal para monorepos TypeScript onde você controla toda a stack. Ao usar middlewares, composição de routers e tratamento de erros estruturado, você constrói APIs robustas com a mesma segurança de tipo que teria em código local.

Por fim, compreendemos que a escolha entre tRPC, REST e GraphQL depende do contexto: use tRPC para rapidez e type-safety em monorepos, REST para APIs públicas que serão consumidas por múltiplas linguagens, e GraphQL para APIs complexas com queries muito variadas.

Referências


Artigos relacionados