blog.dopana

Back

tRPC là framework TypeScript thuần xây API type-safe giữa client và server. Không schema riêng, không codegen — TypeScript compiler làm hết.

Client → tRPC Client → HTTP → tRPC Server → Handler
         type inferred ←———————— type defined
plaintext

REST / GraphQL / tRPC#

RESTGraphQLtRPC
SchemaKhôngSDLTypeScript type
Type safetyManual/client-genCodegenTự động
Over-fetchingKhôngTuỳ procedure
HọcThấpTrung bìnhRất thấp
Tooling ecosystemLớnTrung bìnhNhỏ hơn

tRPC không thay thế REST/GraphQL mọi lúc — nhưng cho fullstack TypeScript, nó giảm đáng kể friction.

Setup#

mkdir my-app && cd my-app
npm init -y
npm install @trpc/server @trpc/client zod
npx tsc --init
bash

Server#

Client#

Phía client không cần share type bằng tay — import AppRouter là TypeScript tự suy toàn bộ.

Zod Input Validation#

input() nhận bất kỳ parser nào. Zod là phổ biến nhất:

const userRouter = t.router({
  create: t.procedure
    .input(
      z.object({
        email: z.string().email(),
        name: z.string().min(2).max(100),
        age: z.number().int().positive().optional(),
      })
    )
    .mutation(async ({ input }) => {
      // input đã được validate + typed
      return createUser(input);
    }),
});
typescript

Validation chạy ở server. Lỗi trả về dạng TRPCError code BAD_REQUEST tự động.

Context & Middleware#

Context được tạo mỗi request:

createHTTPServer({
  router: todoRouter,
  createContext({ req }) {
    const token = req.headers.authorization?.split(' ')[1];
    const user = token ? verifyToken(token) : null;
    return { user };
  },
});
typescript

Middleware cho phép kiểm tra auth tập trung:

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

const authedProcedure = t.procedure.use(isAuthed);

const authRouter = t.router({
  me: authedProcedure.query(({ ctx }) => ctx.user),
});
typescript

Error Handling#

const updateRouter = t.router({
  update: t.procedure
    .input(z.object({ id: z.number() }))
    .mutation(async ({ input, ctx }) => {
      const todo = await db.select().from(todos).where(eq(todos.id, input.id));
      if (!todo) throw new TRPCError({
        code: 'NOT_FOUND',
        message: 'Todo không tồn tại',
      });
      if (todo.userId !== ctx.user.id) throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Không có quyền sửa todo này',
      });
    }),
});
typescript

Mã lỗi: PARSE_ERROR, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, TIMEOUT, CONFLICT, PRECONDITION_FAILED, PAYLOAD_TOO_LARGE, METHOD_NOT_SUPPORTED, INTERNAL_SERVER_ERROR.

React Integration#

Với React Query tích hợp sẵn: auto-refetch, optimistic update, cache invalidation, loading states.

Server-side Rendering (Next.js)#

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from './_app';

// App Router (Next.js 13+)
export const api = createTRPCNext<AppRouter>({
  config() {
    return { links: [httpBatchLink({ url: '/api/trpc' })] };
  },
  ssr: true,
});
typescript
// pages/api/trpc/[trpc].ts (Pages Router)
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/_app';

export default createNextApiHandler({
  router: appRouter,
  createContext({ req, res }) {
    return { session: await getSession({ req }) };
  },
});
typescript

Với Next.js App Router:

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/_app';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({ session: await auth() }),
  });

export { handler as GET, handler as POST };
typescript

Khi Nào Dùng tRPC?#

✅ Nên dùng#

  • Fullstack TypeScript (monorepo hoặc share type giữa client/server)
  • Internal API, không public
  • MVP / startup — cần ship nhanh
  • Micro-frontend với nhiều service TypeScript
  • App mobile (React Native) + web cùng API

❌ Không nên#

  • API public cho bên thứ ba (cần OpenAPI/Swagger)
  • Dùng nhiều ngôn ngữ backend khác (Python, Go, Rust)
  • Hệ thống legacy không TypeScript
  • Cần caching HTTP tầng CDN / reverse proxy mạnh

So Sánh Chi Tiết#

tRPC vs REST#

// REST
GET    /api/todos          → { todos: Todo[] }
POST   /api/todos          → { todo: Todo }
PATCH  /api/todos/:id      → { todo: Todo }

// Client REST
const res = await fetch('/api/todos');
const data = await res.json();
// data: any — mất type, dễ bug

// tRPC
const todos = await trpc.todo.list.query();
// todos: Todo[] — type-safe, autocomplete
typescript

tRPC vs GraphQL#

# GraphQL
query { todos { id text done } }

# Type định nghĩa riêng SDL → codegen → TypeScript
# Mỗi lần thay đổi schema: chạy codegen lại

# tRPC
await trpc.todo.list.query();
# Type từ server, zero codegen
# Schema = TypeScript type duy nhất
graphql

Subscription (Realtime)#

const roomRouter = t.router({
  onMessage: t.procedure
    .subscription(async function* ({ input }) {
      for await (const msg of subscribeToRoom(input.roomId)) {
        yield { message: msg };
      }
    }),
});
typescript
// React hook
const { data } = trpc.room.onMessage.useSubscription(
  { roomId: 'general' },
  { onData: (msg) => console.log(msg) }
);
typescript

Dùng WebSocket phía dưới. Async generator pattern — clean, không callback hell.

Tổ Chức Router#

// server/_app.ts — root router
import { router } from './trpc';
import { todoRouter } from './routers/todo';
import { userRouter } from './routers/user';
import { roomRouter } from './routers/room';

export const appRouter = router({
  todo: todoRouter,
  user: userRouter,
  room: roomRouter,
});

export type AppRouter = typeof appRouter;
typescript
// server/routers/todo.ts
export const todoRouter = t.router({
  list: t.procedure.query(() => ...),
  add: t.procedure.input(z.object({...})).mutation(() => ...),
});
typescript

Chia router theo domain. Client gọi trpc.todo.list.query(), trpc.user.me.query() — namespace tự động.

Middleware Pattern: Rate Limiting#

const rateLimit = t.middleware(async ({ ctx, next }) => {
  const key = `rate:${ctx.ip}`;
  const hits = await redis.incr(key);
  if (hits > 100) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
  await redis.expire(key, 60);
  return next();
});

const publicProcedure = t.procedure.use(rateLimit);
typescript

Error Trả Về Cho Client#

try {
  await trpc.todo.update.mutate({ id: 999 });
} catch (error) {
  if (error instanceof TRPCClientError) {
    if (error.data.code === 'NOT_FOUND') {
      notify('Todo không tồn tại');
    }
  }
}
typescript

Server trả JSON: { code: 'NOT_FOUND', message: '...', httpStatus: 404 }. Client parse type-safe.

Tổng Kết#

tRPC giải quyết vấn đề đau nhất của fullstack TypeScript: type safety không ma sát.

Ưu điểmNhược điểm
Zero codegen — Type compiler làm việcChỉ TypeScript
Tự động suy kiểu đầu raKhông có schema language
Validation tích hợp sẵnPublic API khó dùng
React Query tích hợp ngayCộng đồng nhỏ hơn
Dễ học — vài dòng code

tRPC không phải viên đạn bạc. Trong hệ sinh thái fullstack TypeScript, nó là công cụ giảm friction tối đa — focus vào logic thay vì cầu nối.

// Tóm lại: từ server...
export const appRouter = t.router({
  hello: t.procedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => `Hello, ${input.name}!`),
});

// ...đến client chỉ cần 1 dòng
const msg = await trpc.hello.query({ name: 'World' }); // "Hello, World!"
// ✅ Type-safe từ đầu đến cuối
typescript

Tài liệu tham khảo#