Skip to main content
Back to Journal
TypeScriptWeb Development

tRPC: Full-Stack Type Safety Without GraphQL

Every full-stack TypeScript developer hits the same wall eventually. You have types on the server. You have types on the client. But the HTTP layer between them is a type-free void. Your REST endpoints accept any and return any. You might write TypeScript interfaces on both sides and hope they stay in sync, but there is no compiler enforcing that contract.

GraphQL solves this with a schema language and code generation. You write your schema in SDL, run a codegen step, and get typed client code. It works, but it is a lot of machinery for a TypeScript monorepo where both the server and client are already in the same language.

tRPC takes a radically different approach. It shares types directly between server and client through TypeScript inference. No schema language. No code generation. No runtime overhead. You define a procedure on the server, and the client automatically knows its input types, output types, and error types. Change a field name on the server, and you get a compile error on the client instantly.

How It Works

The core concept is a router. You create procedures (queries and mutations) on the server, each with an optional input validator and a resolver function. tRPC uses TypeScript inference to extract the types from these definitions. On the client, you import only the type of the router (not the implementation), and tRPC gives you a fully typed client.

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

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
import { db } from '../db';

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } });
      if (!user) throw new Error('User not found');
      return user;
    }),

  list: publicProcedure
    .query(async () => {
      return db.user.findMany({ orderBy: { createdAt: 'desc' } });
    }),

  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return db.user.create({ data: input });
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(1).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ input }) => {
      const { id, ...data } = input;
      return db.user.update({ where: { id }, data });
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      return db.user.delete({ where: { id: input.id } });
    }),
});

The Zod schemas handle input validation at runtime while simultaneously providing the types at compile time. If you try to call create without a name field, TypeScript catches it in your editor. If someone sends a malformed request over the wire, Zod catches it at runtime. Both layers are derived from the same schema definition.

The Root Router

You compose your feature routers into a single root router and export its type:

// server/routers/index.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

// Export only the type, not the implementation
export type AppRouter = typeof appRouter;

The export type is critical. It exports only the type information, which gets erased at compile time. No server code leaks to the client bundle. The client only gets the type signatures, which it uses for autocompletion and type checking.

React Query Integration

On the client, tRPC integrates with React Query (now TanStack Query) through the @trpc/react-query package. This gives you the full power of React Query (caching, refetching, optimistic updates, infinite queries) with complete type safety.

// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers';

export const trpc = createTRPCReact<AppRouter>();
// client/components/UserList.tsx
import { trpc } from '../trpc';

function UserList() {
  const { data: users, isLoading } = trpc.user.list.useQuery();

  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch the user list
      utils.user.list.invalidate();
    },
  });

  const utils = trpc.useUtils();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {users?.map(user => (
        <div key={user.id}>{user.name} - {user.email}</div>
      ))}
      <button onClick={() => createUser.mutate({ name: 'Bryan', email: '[email protected]' })}>
        Add User
      </button>
    </div>
  );
}

Every property in this code is fully typed. users is typed as whatever your Prisma query returns. createUser.mutate requires exactly the fields defined in the Zod schema. If you misspell email as emal, TypeScript flags it immediately. If you change the schema on the server to require a role field, every client call that is missing role becomes a compile error.

Middleware for Authentication

tRPC procedures support middleware, which is how you handle cross-cutting concerns like authentication. You create a protected procedure that verifies the user session before the resolver runs.

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

const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in',
    });
  }

  return next({
    ctx: {
      user: ctx.session.user,  // user is now guaranteed in ctx
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

Now any procedure built with protectedProcedure instead of publicProcedure automatically requires authentication. The ctx.user is guaranteed to exist in the resolver because the middleware throws before the resolver runs if there is no session. TypeScript knows this too, so ctx.user is not nullable inside protected resolvers.

export const userRouter = router({
  me: protectedProcedure
    .query(async ({ ctx }) => {
      // ctx.user is guaranteed to exist and is fully typed
      return db.user.findUnique({ where: { id: ctx.user.id } });
    }),
});

Error Handling

tRPC has a built-in error system with standard error codes (NOT_FOUND, UNAUTHORIZED, BAD_REQUEST, etc.) that map to HTTP status codes. Errors thrown inside procedures are serialized and sent to the client in a consistent format.

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

// In a procedure
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) {
  throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'User with id ' + input.id + ' not found',
  });
}

On the client, React Query's error handling picks up tRPC errors naturally. You get typed error objects with the error code and message.

When to Use tRPC vs. REST vs. GraphQL

This is the question I get most often. Here is my decision framework:

Use tRPC when: Both your server and client are TypeScript. You control both ends. You are building a web application (not a public API). You want maximum type safety with minimum ceremony. Monorepos are the ideal setup because the type inference works across packages.

Use REST when: You are building a public API that will be consumed by third parties. Your consumers use different languages. You need maximum interoperability and simplicity. REST with OpenAPI spec generation is the pragmatic choice for public APIs.

Use GraphQL when: You have multiple clients (web, mobile, smart TV) with very different data requirements. The schema acts as a contract between teams. You need fine-grained field selection because bandwidth matters (mobile clients on slow networks). You are okay with the complexity of a schema language, code generation, and a runtime query parser.

For most full-stack TypeScript applications where you own both the server and client, tRPC is the right choice. The developer experience is unmatched. You get end-to-end type safety, auto-complete in your editor, and compile-time guarantees that your client and server agree on the contract. All of this without writing a single line of schema definition or running any code generation step. You just write TypeScript, and the types flow.

The Bigger Picture

tRPC is part of a broader trend toward tighter integration between server and client in TypeScript applications. When the same language runs on both sides, the traditional ceremony of API layers (schema definitions, code generation, manual type declarations) becomes unnecessary overhead. tRPC strips away that overhead and lets TypeScript's type system do what it was designed to do: ensure correctness across your entire codebase.

If you have been manually syncing types between your server and client, or writing fetch wrappers with hand-written response types, give tRPC a try. The setup takes about 15 minutes, and the first time your editor autocompletes a server response field on the client, you will wonder why you ever did it the old way.

trpctype-safetyapitypescriptfull-stack