Projeto final: API type-safe de ponta a ponta com tRPC e Prisma
1. Introdução ao ecossistema type-safe: Prisma + tRPC
Um dos maiores desafios no desenvolvimento web moderno é manter a consistência de tipos entre frontend e backend. Em arquiteturas tradicionais com REST ou GraphQL, é comum que mudanças no modelo de dados do servidor não sejam refletidas automaticamente no cliente, gerando bugs em tempo de execução que poderiam ser capturados em compilação.
O Prisma resolve metade do problema: como ORM, ele gera tipos TypeScript automaticamente a partir do schema do banco de dados. Quando você define um modelo User, o Prisma cria tipos como Prisma.UserCreateInput e Prisma.UserWhereUniqueInput, garantindo que suas consultas ao banco sejam tipadas.
O tRPC completa o ciclo eliminando a necessidade de contratos manuais de API. Em vez de escrever endpoints REST ou resolvers GraphQL, você define procedimentos diretamente no servidor que são consumidos pelo cliente com inferência total de tipos.
// schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String
}
// server/router.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.user.findUnique({ where: { id: input.id } });
}),
});
export type AppRouter = typeof appRouter;
No cliente, o consumo é direto e tipado:
// client/UserComponent.tsx
const { data } = trpc.getUser.useQuery({ id: '123' });
// data é automaticamente tipado como User | undefined
2. Configuração do projeto monorepo com TypeScript
Para um projeto profissional, organize o código em um monorepo com três pacotes:
project/
├── packages/
│ ├── server/ # Servidor com Prisma e tRPC
│ ├── client/ # Frontend React/Next.js
│ └── shared/ # Tipos compartilhados (opcional)
├── package.json # Workspaces
└── tsconfig.json # Base config
Configure o tsconfig.json base com strict mode:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
No servidor, instale as dependências essenciais:
npm install @prisma/client @trpc/server zod
npm install -D prisma typescript
Execute prisma init e depois prisma generate para criar o cliente tipado.
3. Definição do schema Prisma e tipos de banco de dados
Crie um schema completo com modelos, relacionamentos e enumerações:
// packages/server/prisma/schema.prisma
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
Após prisma generate, os tipos gerados incluem:
// Tipos gerados automaticamente
type UserCreateInput = {
email: string;
name: string;
role?: Role;
posts?: PostCreateNestedManyWithoutAuthorInput;
};
type UserWhereUniqueInput = {
id?: string;
email?: string;
};
4. Construção do router tRPC com tipagem forte
Crie o router com procedimentos fortemente tipados, usando Zod para validação:
// packages/server/src/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const t = initTRPC.create();
export const appRouter = t.router({
// Query com validação de input
getUser: t.procedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ input }) => {
return prisma.user.findUnique({
where: { id: input.id },
include: { posts: true }
});
}),
// Mutation com validação e resposta tipada
createUser: t.procedure
.input(z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['USER', 'ADMIN']).optional()
}))
.mutation(async ({ input }) => {
return prisma.user.create({ data: input });
}),
// Procedimento com relacionamento
createPost: t.procedure
.input(z.object({
title: z.string().min(1),
content: z.string().optional(),
authorId: z.string().cuid()
}))
.mutation(async ({ input }) => {
return prisma.post.create({
data: input,
include: { author: true }
});
})
});
export type AppRouter = typeof appRouter;
5. Configuração do cliente tRPC no frontend
No frontend, configure o cliente com inferência total de tipos:
// packages/client/src/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@server/router';
export const trpc = createTRPCReact<AppRouter>();
Configure o provider no componente raiz:
// packages/client/src/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })]
});
export function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
</trpc.Provider>
);
}
Agora os hooks são totalmente tipados:
// packages/client/src/UserList.tsx
function UserList() {
// Autocompletar funciona perfeitamente
const { data: users, error } = trpc.getUsers.useQuery({ limit: 10 });
const createUser = trpc.createUser.useMutation({
onSuccess: () => queryClient.invalidateQueries(['getUsers'])
});
// Erros do servidor são tipados
if (error) {
return <div>Erro: {error.message}</div>;
}
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
);
}
6. Ponta a ponta: integração entre frontend e backend
A verdadeira magia acontece quando uma mudança no schema Prisma quebra a compilação em ambos os lados simultaneamente. Se você renomear o campo email para emailAddress no schema:
// schema.prisma
model User {
emailAddress String @unique // Antes: email
}
Após prisma generate, o TypeScript reportará erros tanto no servidor:
// server/router.ts - ERRO!
return prisma.user.findUnique({ where: { email: input.email } });
// Property 'email' does not exist on type 'UserWhereUniqueInput'
Quanto no cliente:
// client/UserList.tsx - ERRO!
createUser.mutate({ email: 'test@test.com' });
// Argument of type '{ email: string }' is not assignable
Isso elimina uma classe inteira de bugs de produção.
7. Boas práticas e otimizações para produção
Separe contextos para autenticação e acesso ao Prisma:
// packages/server/src/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function createContext() {
return { prisma };
}
export type Context = inferAsyncReturnType<typeof createContext>;
Use middlewares para autorização tipada:
// packages/server/src/middleware.ts
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.user?.role !== 'ADMIN') {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
const adminProcedure = t.procedure.use(isAdmin);
Otimize consultas com select para evitar dados desnecessários:
getUserPreview: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return prisma.user.findUnique({
where: { id: input.id },
select: { id: true, name: true } // Apenas campos necessários
});
})
8. Conclusão e próximos passos
A combinação Prisma + tRPC oferece o mais alto nível de type-safety atualmente possível no ecossistema TypeScript. Diferente de REST sem tipos (que depende de documentação manual) ou GraphQL com codegen (que adiciona complexidade), essa stack oferece tipos inferidos automaticamente com zero boilerplate.
Para produção, considere:
- Adicionar testes unitários com tipos usando tsd ou expect-type
- Implementar rate limiting e logging estruturado
- Configurar monitoramento com OpenTelemetry
- Explorar o fallback de tipos com satisfies para validações complexas
Referências
- Documentação oficial do tRPC — Guia completo de configuração, procedimentos e middleware para APIs type-safe
- Documentação do Prisma — Referência completa do schema, relacionamentos e geração de tipos
- tRPC + Prisma: Full-stack Type Safety — Tutorial oficial da Prisma sobre integração com tRPC
- Zod: validação de schemas TypeScript — Biblioteca de validação usada com tRPC para inputs tipados
- Monorepo TypeScript com npm workspaces — Guia para estruturar projetos multi-pacote como o deste artigo
- React Query + tRPC: gerenciamento de estado servidor — Documentação do TanStack Query usado como cache no cliente tRPC
- Exemplo completo: tRPC + Prisma + Next.js — Repositório oficial com projeto starter funcional