tRPC in Practice: End-to-End Type Safety, Zod Validation, and React Query Integration

前端工程

Why tRPC Is Worth Using

Traditional frontend/backend collaboration relies on "hand-written API docs + separately maintained types," which drift apart the moment a type changes. tRPC's core pitch: your backend procedure IS the type source — the frontend gets full type inference on call, with no codegen and no schema files.

Approach Type source Needs codegen Cross-language
REST + hand types manual no yes
GraphQL + codegen schema file yes yes
tRPC derived from backend proc no no (TS/TS only)

Defining Routers and Procedures

// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

// server/routers/user.ts
export const userRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
  create: publicProcedure
    .input(z.object({ name: z.string().min(1), email: z.string().email() }))
    .mutation(({ input }) => {
      return db.user.create({ data: input });
    }),
});

query maps to reads, mutation to writes. Input is declared with Zod, and tRPC validates it at runtime automatically.

For complex nested inputs, run the shape through the JSON Formatter tool first, then translate it into a Zod schema to avoid missing fields.


React Query Integration

This is the classic combo: tRPC owns types, React Query owns caching/retry/invalidation.

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

export const trpc = createTRPCReact<AppRouter>();

function UserList() {
  const users = trpc.user.list.useQuery();
  const utils = trpc.useUtils();
  const create = trpc.user.create.useMutation({
    onSuccess: () => utils.user.list.invalidate(),
  });

  if (users.isLoading) return <p>Loading…</p>;
  return (
    <ul>
      {users.data?.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
      <button onClick={() => create.mutate({ name: "Ada", email: "a@x.com" })}>
        Add
      </button>
    </ul>
  );
}

Note that the type of users.data and the argument type of create.mutate are all inferred from the backend procedure — change the backend input and the frontend errors immediately.


Middleware and Auth

import { initTRPC, TRPCError } from "@trpc/server";

const t = initTRPC.context<{ jwt?: string }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.jwt) throw new TRPCError({ code: "UNAUTHORIZED" });
  return next({ ctx: { ...ctx, userId: verify(ctx.jwt) } });
});

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

Middleware can rewrite ctx, so downstream procedures receive userId.

When validating JWTs, use the JWT Decoder tool to inspect sub and expiry in the payload and quickly verify the auth chain.


Error Handling

tRPC unifies errors with TRPCError; the frontend distinguishes them via error.data.code:

protectedProcedure.query(({ ctx }) => {
  const u = db.user.findUnique({ where: { id: ctx.userId } });
  if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  return u;
});

Frontend:

const { error } = trpc.user.me.useQuery();
if (error?.data?.code === "NOT_FOUND") return <p>Not found</p>;

Error codes map 1:1 to HTTP status codes; when debugging, the HTTP Status Codes tool maps UNAUTHORIZED=401, NOT_FOUND=404, etc.


Subscriptions (Realtime)

tRPC supports WebSocket-based subscriptions:

export const appRouter = router({
  onMessage: publicProcedure.subscription(({ signal }) => {
    return observable<Message>((emit) => {
      const timer = setInterval(() => emit.next({ text: "ping" }), 1000);
      return () => clearInterval(timer);
    });
  }),
});

Smooth Migration from REST

  1. Map boundaries: "reads" → query, "writes/side effects" → mutation.
  2. Add Zod: give every endpoint an input schema — you get runtime validation for free.
  3. Replace client: swap fetch + hand types for trpc.xxx.useQuery/useMutation.
  4. Sink auth: consolidate scattered JWT checks into the protectedProcedure middleware.
  5. Keep REST: tRPC can also expose HTTP endpoints via a fetch adapter for incremental migration.

FAQ

Q1: Can tRPC be cross-language?

No. It relies on TypeScript type inference; both ends must be TS. For multi-language, fall back to REST/GraphQL.

Q2: How does it compare to GraphQL?

It removes schema files and codegen — types are the source code. The trade-off is losing cross-language and public-API capability.

Q3: What happens on Zod validation failure?

It automatically becomes a BAD_REQUEST error; the frontend sees error.data.code === "BAD_REQUEST" with the Zod issue list attached.

Q4: Can I avoid React Query?

Yes. The tRPC core is client-agnostic and has a native client caller, but the React Query integration has the best DX.

Q5: How do I paginate large lists?

Add cursor/limit to the input, return { items, nextCursor } from the query, and pair it with useInfiniteQuery on the frontend.


In tRPC full-stack development, these tools help:


tRPC is not "yet another RPC framework" but a "types as contract" paradigm: the backend procedure becomes the frontend's type source, eliminating docs, codegen, and the mental overhead of matching fields on both sides. Change a backend input and the editor instantly reds the frontend — that end-to-end type safety is the sweet spot of modern full-stack TS teams.

Try these browser-local tools — no sign-up required →

#tRPC#类型安全#全栈#API#Zod#React Query#前端