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
- tRPC Documentation — Documentação oficial completa
- tRPC GitHub Repository — Código-fonte e exemplos
- TypeScript Handbook — Fundamentos do TypeScript
- Zod Validation Library — Documentação para validação de dados
- Building Type-Safe APIs with tRPC — Talk introdutória sobre o tema