Contexto
tRPC e GraphQL resolvem o mesmo problema, um contrato tipado entre cliente e servidor, mas discordam sobre onde esse contrato mora. Em tRPC, o contrato é o próprio TypeScript. O servidor define um router, o cliente importa o tipo desse router e infere de graça as entradas e saídas de cada procedure. Não há arquivo de schema, não há SDL, não há passo de codegen. A consequência direta é que isso só funciona quando cliente e servidor são ambos TypeScript, de preferência no mesmo repositório. Em GraphQL, o contrato é um schema escrito em SDL, independente de linguagem. O servidor expõe esse schema, e clientes em qualquer linguagem conversam com ele por HTTP; para ter tipos em TypeScript, você roda um codegen contra o schema.
Daí saem dois perfis opostos. tRPC acopla cliente e servidor à mesma base TypeScript e, em troca, elimina toda a cerimônia de schema e geração de código: mudou o router, o cliente já vê os novos tipos sem rebuild de contrato. GraphQL faz o contrário: desacopla as pontas (qualquer linguagem, clientes públicos, times separados) e resolve over e under-fetching deixando o cliente escolher exatamente os campos que quer, ao custo de manter SDL, resolvers, codegen e um ecossistema de ferramentas. Vale ler os números com cuidado: graphql marca cerca de 42,5M de downloads por semana, mas é a implementação de referência em JavaScript, base de quase todo o ecossistema GraphQL, enquanto @trpc/server (cerca de 4M/sem) é um pacote de servidor específico. Não é uma disputa de popularidade 1:1; é sinal de que GraphQL é um padrão de indústria e tRPC é uma solução de nicho muito bem resolvida.
Quando escolher tRPC
tRPC brilha quando cliente e servidor são TypeScript no mesmo repositório e você quer o contrato mais barato possível: nenhum. Você define procedures no servidor e consome no cliente com inferência ponta a ponta.
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure
.input(z.string())
.query(({ input }) => ({ id: input, name: "Ada" })),
});
// o cliente infere input e output deste tipo, sem codegen
export type AppRouter = typeof appRouter;
Do lado do cliente, basta importar o tipo AppRouter: chamar trpc.userById.query("1") é tipado nas duas pontas, a entrada é string e a saída é { id: string; name: string }, sem uma linha de codegen. Renomeie um campo no servidor e o cliente para de compilar na hora, no seu editor, antes de qualquer request. É o loop de feedback mais curto que existe entre back e front, e o pacote é minúsculo (cerca de 5,6 kB gzip). O limite é rígido: isso vale só de TypeScript para TypeScript. Um app mobile em Swift, um parceiro externo ou qualquer cliente que não compartilhe seu código não consome tRPC sem você construir uma camada HTTP por cima, e nesse ponto você já está reinventando parte do que GraphQL ou REST entregam de fábrica.
Quando escolher GraphQL
GraphQL faz sentido quando a API é pública, poliglota ou consumida por clientes que você não controla, e quando faz diferença deixar cada cliente pedir só os campos de que precisa. O contrato é um schema em SDL, e é ele, não o seu código TypeScript, a fonte da verdade.
import { buildSchema, graphql } from "graphql";
const schema = buildSchema(`
type User { id: ID!, name: String! }
type Query { userById(id: ID!): User }
`);
const root = {
userById: ({ id }: { id: string }) => ({ id, name: "Ada" }),
};
const result = await graphql({
schema,
source: '{ userById(id: "1") { name } }',
rootValue: root,
});
O source acima pede só name, e é exatamente isso que volta: nada de over-fetching, e nada de criar um endpoint novo quando o cliente precisa de um campo a mais. Como o schema é SDL, qualquer cliente (JavaScript, Swift, Python, Go) consulta a mesma API por HTTP, e você ganha de brinde ferramentas como GraphiQL para explorar o schema, Apollo e registries de schema para versionar o contrato. Em TypeScript, a tipagem forte volta via codegen: você aponta o gerador para o schema e ele emite os tipos do cliente. O custo é justamente essa soma de peças: SDL, resolvers, codegen e um pacote bem maior (cerca de 56,6 kB gzip contra 5,6 kB do tRPC). Para um monorepo 100% TypeScript com um único cliente, é maquinaria demais para um problema que a inferência do tRPC resolve sem nenhuma.
Veredito
O desempate cabe numa pergunta: todo consumidor dessa API é TypeScript e compartilha o seu código? Se a resposta é sim, tRPC apaga a camada inteira de schema e codegen e entrega tipos ponta a ponta de graça, com o menor pacote dos dois. Se algum consumidor fala outra linguagem, é público ou vive fora do seu repositório, tRPC simplesmente não alcança, e o schema SDL agnóstico de linguagem do GraphQL, somado à seleção de campos pelo cliente, passa a ser a ferramenta certa, mesmo com a maquinaria extra.
Os números não devem enganar: os 42,5M de downloads do graphql medem um padrão de indústria inteiro, não uma preferência direta sobre tRPC. Os dois atendem formatos diferentes de problema. tRPC é para full-stack TypeScript interno, o monorepo estilo Next.js em que o mesmo time é dono do back e do front. GraphQL é para um contrato que vive mais tempo e alcança mais clientes do que o seu próprio front. Não escolha GraphQL só para se precaver num monorepo TypeScript: é complexidade acidental que a inferência do tRPC dispensa. E não force tRPC numa API pública ou poliglota: ele não vai além de TypeScript. Decida pela audiência da API, não pelo hype, e a escolha praticamente se faz sozinha.