typescript 91 lines · 4 tabs

tRPC router pattern for type-safe APIs

Shared by codesnips Jan 2026
4 tabs
import { initTRPC, TRPCError } from '@trpc/server';

export interface Context {
  user: { id: string; role: 'user' | 'admin' } | null;
  db: DatabaseClient;
}

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required' });
  }
  return next({ ctx: { user: ctx.user } });
});

export const protectedProcedure = t.procedure.use(isAuthed);
4 files · typescript Explain with highlit

This snippet shows the core of a tRPC setup: how a server-side router is defined, how procedures are protected with middleware, and how a client calls them with full end-to-end type inference and no code generation. The value of tRPC is that the input and output types flow from the server definitions straight into the client, so a rename or a schema change surfaces as a compile error at every call site.

In trpc.ts, the builder is initialized once with initTRPC.context<Context>().create(). The generic Context type — carrying an optional user and a db handle — is what every procedure sees at runtime. From this instance the module re-exports router, publicProcedure, and a reusable protectedProcedure. The isAuthed middleware inspects ctx.user; if it is missing it throws a TRPCError with code: 'UNAUTHORIZED', otherwise it calls next({ ctx: { user: ctx.user } }). That narrowing is the important part: after the middleware, ctx.user is typed as non-null downstream, so protected resolvers never need to re-check for null.

In postRouter.ts, each procedure attaches a Zod schema via .input(...). Zod does double duty here — it validates the request at runtime and, because tRPC reads its inferred type, it also types the input argument in the resolver. create is a protectedProcedure.mutation, so it can safely read ctx.user.id. byId is a publicProcedure.query that throws NOT_FOUND when the row is missing. The list query uses .default(...) values so the client may omit them entirely, demonstrating how optional inputs stay type-safe.

The routers compose in appRouter.ts through router({ post: postRouter }), and the exported AppRouter type is the single artifact the client imports — a type, never runtime code, so nothing server-side ships to the browser.

In client.ts, createTRPCProxyClient<AppRouter>() produces a proxy whose method paths mirror the router tree. Calls like trpc.post.byId.query(...) are fully autocompleted and checked; passing a wrong field is a build error. This pattern suits fullstack TypeScript monorepos where server and client share a type boundary, trading framework lock-in for eliminating an entire class of API drift bugs.


Related snips

Share this code

Here's the card — post it anywhere.

tRPC router pattern for type-safe APIs — share card
Link copied