TypeScript

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

12 min de leitura

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

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

<h2>O Problema das APIs Tradicionais</h2>

<p>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.</p>

<p>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.</p>

<h2>Entendendo o tRPC</h2>

<h3>O Conceito Fundamental</h3>

<p>tRPC significa &quot;TypeScript Remote Procedure Call&quot; — é 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.</p>

<p>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.</p>

<pre><code class="language-typescript">// backend/router.ts

import { initTRPC } from &#039;@trpc/server&#039;;

const t = initTRPC.create();

export const router = t.router({

user: t.procedure

.input(z.object({ id: z.string() }))

.query(async ({ input }) =&gt; {

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;</code></pre>

<h3>Por Que Funciona Sem Geração de Código</h3>

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

<pre><code class="language-typescript">// frontend/client.ts

import { createTRPCClient, httpBatchLink } from &#039;@trpc/client&#039;;

import type { AppRouter } from &#039;../backend/router&#039;;

const trpc = createTRPCClient&lt;AppRouter&gt;({

links: [

httpBatchLink({

url: &#039;http://localhost:3000/trpc&#039;,

}),

],

});

// Agora qualquer chamada é totalmente tipada

const user = await trpc.user.query({ id: &#039;123&#039; });

// TypeScript sabe que &#039;user&#039; tem as propriedades: id, name, email</code></pre>

<p>Aqui está o ponto crítico: o tipo <code>AppRouter</code> é 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.</p>

<h2>Construindo uma API Completa com tRPC</h2>

<h3>Estrutura de um Servidor tRPC</h3>

<p>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).</p>

<pre><code class="language-typescript">// server.ts

import { initTRPC } from &#039;@trpc/server&#039;;

import { createHTTPServer } from &#039;@trpc/server/adapters/standalone&#039;;

import { z } from &#039;zod&#039;;

const t = initTRPC.create();

interface User {

id: string;

name: string;

email: string;

}

const users: User[] = [

{ id: &#039;1&#039;, name: &#039;Alice&#039;, email: &#039;alice@example.com&#039; },

{ id: &#039;2&#039;, name: &#039;Bob&#039;, email: &#039;bob@example.com&#039; },

];

export const appRouter = t.router({

// Query: buscar usuário

getUser: t.procedure

.input(z.object({ id: z.string() }))

.query(({ input }) =&gt; {

return users.find(u =&gt; u.id === input.id);

}),

// Query: listar todos os usuários

listUsers: t.procedure

.query(() =&gt; {

return users;

}),

// Mutation: criar usuário

createUser: t.procedure

.input(z.object({

name: z.string(),

email: z.string().email(),

}))

.mutation(({ input }) =&gt; {

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 }) =&gt; {

const user = users.find(u =&gt; u.id === input.id);

if (!user) throw new Error(&#039;Usuário não encontrado&#039;);

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, () =&gt; {

console.log(&#039;tRPC server running on http://localhost:3000&#039;);

});</code></pre>

<p>Note que usamos <code>zod</code> 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.</p>

<h3>Cliente Consumindo a API</h3>

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

<pre><code class="language-typescript">// client.ts

import { createTRPCClient, httpBatchLink } from &#039;@trpc/client&#039;;

import type { AppRouter } from &#039;./server&#039;;

const trpc = createTRPCClient&lt;AppRouter&gt;({

links: [

httpBatchLink({

url: &#039;http://localhost:3000&#039;,

}),

],

});

async function main() {

// Buscar um usuário

const user = await trpc.getUser.query({ id: &#039;1&#039; });

console.log(user); // { id: &#039;1&#039;, name: &#039;Alice&#039;, email: &#039;alice@example.com&#039; }

// Listar todos

const allUsers = await trpc.listUsers.query();

console.log(allUsers);

// Criar novo usuário

const newUser = await trpc.createUser.mutation({

name: &#039;Charlie&#039;,

email: &#039;charlie@example.com&#039;,

});

console.log(newUser);

// Atualizar usuário

const updated = await trpc.updateUser.mutation({

id: &#039;1&#039;,

name: &#039;Alice Updated&#039;,

});

console.log(updated);

}

main();</code></pre>

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

<pre><code class="language-typescript"></code></pre>

<h2>Padrões Avançados e Boas Práticas</h2>

<h3>Reutilizando Lógica com Middlewares</h3>

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

<pre><code class="language-typescript">import { initTRPC, TRPCError } from &#039;@trpc/server&#039;;

const t = initTRPC

.context&lt;{ userId?: string }&gt;()

.create();

// Middleware de autenticação

const isAuthenticated = t.middleware(({ ctx, next }) =&gt; {

if (!ctx.userId) {

throw new TRPCError({ code: &#039;UNAUTHORIZED&#039; });

}

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(() =&gt; ({ message: &#039;Dados públicos&#039; })),

// Procedure protegida

getPrivateData: protectedProcedure

.query(({ ctx }) =&gt; {

return { message: Dados privados do usuário ${ctx.userId} };

}),

// Mutation protegida

updateProfile: protectedProcedure

.input(z.object({ name: z.string() }))

.mutation(({ ctx, input }) =&gt; {

return { userId: ctx.userId, name: input.name };

}),

});</code></pre>

<h3>Composição de Routers</h3>

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

<pre><code class="language-typescript">// users.ts

const usersRouter = t.router({

list: t.procedure.query(() =&gt; users),

getById: t.procedure

.input(z.string())

.query(({ input }) =&gt; users.find(u =&gt; u.id === input)),

});

// posts.ts

const postsRouter = t.router({

list: t.procedure.query(() =&gt; posts),

create: t.procedure

.input(z.object({ title: z.string(), userId: z.string() }))

.mutation(({ input }) =&gt; {

const post = { id: generateId(), ...input };

posts.push(post);

return post;

}),

});

// router.ts (composição)

export const appRouter = t.router({

user: usersRouter,

post: postsRouter,

});</code></pre>

<p>No cliente, a estrutura se reflete automaticamente:</p>

<pre><code class="language-typescript">// Acesso aos procedures via namespaces

await trpc.user.list.query();

await trpc.user.getById.query(&#039;123&#039;);

await trpc.post.create.mutation({ title: &#039;Hello&#039;, userId: &#039;1&#039; });</code></pre>

<h3>Tratamento de Erros</h3>

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

<pre><code class="language-typescript">import { TRPCError } from &#039;@trpc/server&#039;;

const appRouter = t.router({

deleteUser: t.procedure

.input(z.string())

.mutation(({ input }) =&gt; {

const user = users.find(u =&gt; u.id === input);

if (!user) {

throw new TRPCError({

code: &#039;NOT_FOUND&#039;,

message: &#039;Usuário não encontrado&#039;,

});

}

if (user.isAdmin) {

throw new TRPCError({

code: &#039;FORBIDDEN&#039;,

message: &#039;Não é possível deletar um admin&#039;,

});

}

const index = users.indexOf(user);

users.splice(index, 1);

return { success: true };

}),

});

// No cliente, você pode tratar erros assim:

try {

await trpc.deleteUser.mutation(&#039;123&#039;);

} catch (error) {

if (error.code === &#039;NOT_FOUND&#039;) {

console.log(&#039;Usuário não existe&#039;);

} else if (error.code === &#039;FORBIDDEN&#039;) {

console.log(&#039;Não tem permissão&#039;);

}

}</code></pre>

<h2>Comparação com Alternativas</h2>

<h3>REST vs tRPC</h3>

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

<pre><code class="language-typescript">// REST (sem type-safety)

const response = await fetch(&#039;/api/users/123&#039;);

const user = await response.json();

// TypeScript não sabe qual é a forma de &#039;user&#039;

console.log(user.name); // Pode quebrar em runtime

// tRPC (com type-safety)

const user = await trpc.getUser.query({ id: &#039;123&#039; });

console.log(user.name); // TypeScript garante que existe</code></pre>

<h3>GraphQL vs tRPC</h3>

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

<pre><code class="language-typescript">// 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: &#039;123&#039; } });

// tRPC é apenas uma chamada de função tipada

const user = await trpc.getUser.query({ id: &#039;123&#039; });</code></pre>

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

<h2>Conclusão</h2>

<p>Aprendemos que <strong>tRPC elimina o gap de tipagem entre cliente e servidor</strong> 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.</p>

<p>Também vimos que <strong>tRPC é ideal para monorepos TypeScript</strong> 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.</p>

<p>Por fim, compreendemos que <strong>a escolha entre tRPC, REST e GraphQL depende do contexto</strong>: 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.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://trpc.io/docs" target="_blank" rel="noopener noreferrer">tRPC Documentation</a> — Documentação oficial completa</li>

<li><a href="https://github.com/trpc/trpc" target="_blank" rel="noopener noreferrer">tRPC GitHub Repository</a> — Código-fonte e exemplos</li>

<li><a href="https://www.typescriptlang.org/docs/" target="_blank" rel="noopener noreferrer">TypeScript Handbook</a> — Fundamentos do TypeScript</li>

<li><a href="https://zod.dev" target="_blank" rel="noopener noreferrer">Zod Validation Library</a> — Documentação para validação de dados</li>

<li><a href="https://www.youtube.com/watch?v=2LYM8gf184U" target="_blank" rel="noopener noreferrer">Building Type-Safe APIs with tRPC</a> — Talk introdutória sobre o tema</li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em TypeScript

Guia Completo de Formulários com TypeScript: React Hook Form e Zod Integrados
Guia Completo de Formulários com TypeScript: React Hook Form e Zod Integrados

Introdução: Por que React Hook Form com Zod? Trabalhar com formulários em Rea...

Como Usar Constraints e Default Types em Generics TypeScript em Produção
Como Usar Constraints e Default Types em Generics TypeScript em Produção

Entendendo Generics em TypeScript Os generics são um dos recursos mais podero...

Dominando NestJS Avançado: Guards, Interceptors, Pipes e Exception Filters em Projetos Reais
Dominando NestJS Avançado: Guards, Interceptors, Pipes e Exception Filters em Projetos Reais

Introdução ao Middleware de NestJS NestJS é um framework robusto construído s...