tRPC — API Type-Safe Cho TypeScript
tRPC xây API type-safe giữa frontend và backend TypeScript, tự động suy kiểu, không cần schema codegen. So sánh với REST, GraphQL, code demo.
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 definedplaintextREST / GraphQL / tRPC#
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Schema | Không | SDL | TypeScript type |
| Type safety | Manual/client-gen | Codegen | Tự động |
| Over-fetching | Có | Không | Tuỳ procedure |
| Học | Thấp | Trung bình | Rất thấp |
| Tooling ecosystem | Lớn | Trung bình | Nhỏ 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 --initbashServer#
// server.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
const t = initTRPC.create();
const todoRouter = t.router({
list: t.procedure
.query(async () => {
return db.select().from(todos);
}),
add: t.procedure
.input(z.object({ text: z.string().min(1) }))
.mutation(async ({ input }) => {
return db.insert(todos).values({ text: input.text }).returning();
}),
toggle: t.procedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
return db.update(todos)
.set({ done: sql`NOT done` })
.where(eq(todos.id, input.id));
}),
});
export type AppRouter = typeof todoRouter;
createHTTPServer({
router: todoRouter,
createContext() {
return { user: getCurrentUser() };
},
}).listen(3000);typescriptClient#
// client.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000' })],
});
// ✅ Tự động suy kiểu — có autocomplete
const todos = await client.todo.list.query();
// ✅ Input kiểm tra type
await client.todo.add.mutate({ text: 'Học tRPC' });
// ❌ Lỗi compile: missing 'text'
await client.todo.add.mutate({});
// ✅ Output có kiểu đầy đủ
todos[0].text; // string
todos[0].done; // booleantypescriptPhí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);
}),
});typescriptValidation 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 };
},
});typescriptMiddleware 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),
});typescriptError 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',
});
}),
});typescriptMã 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#
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server';
export const trpc = createTRPCReact<AppRouter>();
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
});
export default function App({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
// Component
function TodoList() {
const utils = trpc.useUtils();
// ✅ Tự động caching, refetch, loading/error state
const { data: todos, isLoading } = trpc.todo.list.useQuery();
const addMutation = trpc.todo.add.useMutation({
onSuccess: () => utils.todo.list.invalidate(),
});
if (isLoading) return <Spinner />;
return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => addMutation.mutate({ text: 'New' })}>
Add
</button>
</li>
))}
</ul>
);
}tsxVớ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 }) };
},
});typescriptVớ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 };typescriptKhi 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, autocompletetypescripttRPC 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ấtgraphqlSubscription (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) }
);typescriptDù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(() => ...),
});typescriptChia 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);typescriptError 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');
}
}
}typescriptServer 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ểm | Nhược điểm |
|---|---|
| Zero codegen — Type compiler làm việc | Chỉ TypeScript |
| Tự động suy kiểu đầu ra | Không có schema language |
| Validation tích hợp sẵn | Public API khó dùng |
| React Query tích hợp ngay | Cộ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ốitypescript