1. REST + TypeScript의 숨겨진 거짓말
TypeScript를 쓰면 타입 안전성이 생긴다고 생각하잖아. 근데 REST API를 쓰는 순간, 그 믿음에 금이 가기 시작해.
// 백엔드: Express + TypeScript
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
// 프론트엔드: React + TypeScript
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
const fetchUser = async (id: string): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
return response.json() as User; // ← 여기가 문제야
};
as User — 이 타입 단언(Type Assertion)이 컴파일러를 속이고 있어.
백엔드 개발자가 createdAt 필드명을 created_at으로 바꿨어. TypeScript 에러는 없어. 프론트엔드는 빌드도 잘 돼. 근데 런타임에 user.createdAt이 undefined야.
TypeError: Cannot read properties of undefined
at UserProfile.tsx:23
이게 TypeScript를 쓰는데 런타임 에러가 나는 이유야. 타입은 컴파일 타임에만 존재하고, 네트워크를 통해 오는 데이터는 런타임에 검증을 안 해.
2. Zod: 런타임 스키마 검증
Zod는 TypeScript-first 스키마 검증 라이브러리야. 스키마를 정의하면 두 가지를 동시에 얻어:
- 런타임 검증: 실제 데이터가 스키마와 맞는지 확인
- 타입 추론: 스키마에서 TypeScript 타입 자동 생성
import { z } from 'zod';
// 스키마 정의 한 번으로...
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.string().datetime(),
age: z.number().int().min(0).max(150).optional(),
});
// ...타입을 자동으로 얻어
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; role: 'admin' | 'user' | 'moderator'; createdAt: string; age?: number }
// 런타임 검증
const parseUser = (data: unknown): User => {
return UserSchema.parse(data); // 실패 시 ZodError 던짐
};
// 또는 안전하게
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data.email); // User 타입으로 사용 가능
} else {
console.error(result.error.issues); // ZodError 접근
}
Zod 유용한 패턴들
// 중첩 객체
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2), // ISO 국가 코드
});
const UserWithAddressSchema = UserSchema.extend({
address: AddressSchema,
});
// 유니온 타입
const ResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: UserSchema }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
// 변환(Transform): 입력을 파싱하면서 변환
const DateFromStringSchema = z.string().transform((str) => new Date(str));
// 커스텀 검증
const PasswordSchema = z
.string()
.min(8, '비밀번호는 최소 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다');
// 기존 스키마 재사용
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });
3. tRPC란 무엇인가
tRPC는 TypeScript Remote Procedure Call 라이브러리야. REST 대신 함수를 직접 호출하는 것처럼 API를 작성하고 호출할 수 있게 해줘.
그리고 가장 중요한 건: 별도의 코드 생성 없이 서버 라우터의 타입이 자동으로 클라이언트에 추론돼.
REST 방식:
백엔드: POST /users, 응답 타입 문서화 또는 수동 타입 파일 작성
프론트엔드: 백엔드 타입 복사하거나 공유 패키지에서 import
→ 두 파일이 항상 동기화된 상태를 유지해야 해 (사람이)
tRPC 방식:
백엔드: router에 함수 정의, Zod 입력 스키마 첨부
프론트엔드: router 타입을 import해서 자동 추론
→ 백엔드가 바뀌면 프론트엔드에 즉시 TypeScript 에러
4. tRPC 라우터 설정
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// server/trpc.ts — tRPC 인스턴스 생성
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// Context: 각 요청마다 생성되는 컨텍스트 (인증 정보 등)
interface Context {
userId?: string;
userRole?: 'admin' | 'user';
}
const t = initTRPC.context<Context>().create();
// 재사용 가능한 빌딩 블록
export const router = t.router;
export const publicProcedure = t.procedure;
// 인증 미들웨어
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '로그인이 필요합니다',
});
}
return next({ ctx: { ...ctx, userId: ctx.userId } }); // userId 타입 좁힘
});
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.userRole !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다',
});
}
return next({ ctx });
});
export const protectedProcedure = t.procedure.use(isAuthenticated);
export const adminProcedure = t.procedure.use(isAuthenticated).use(isAdmin);
// server/routers/user.ts — 유저 라우터
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
});
const UpdateUserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
});
export const userRouter = router({
// Query: 데이터 조회
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const user = await db.users.findById(input.id);
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `유저 ${input.id}를 찾을 수 없습니다`,
});
}
return user; // 반환 타입 자동 추론
}),
// Query: 목록 조회 (필터링, 페이지네이션)
list: protectedProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { page, limit, search } = input;
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
where: search ? { name: { contains: search } } : undefined,
});
return { users, total: await db.users.count() };
}),
// Mutation: 데이터 변경
create: publicProcedure
.input(CreateUserSchema)
.mutation(async ({ input }) => {
const existingUser = await db.users.findByEmail(input.email);
if (existingUser) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 사용 중인 이메일입니다',
});
}
const user = await db.users.create(input);
return user;
}),
update: protectedProcedure
.input(UpdateUserSchema)
.mutation(async ({ input, ctx }) => {
// 자신의 정보만 수정 가능
if (ctx.userId !== input.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 없습니다',
});
}
return db.users.update(input);
}),
});
// server/router.ts — 루트 라우터
import { router } from './trpc';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// 타입 내보내기: 클라이언트에서 사용
export type AppRouter = typeof appRouter;
5. Next.js에 tRPC 연결하기
// app/api/trpc/[trpc]/route.ts — Next.js App Router
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/router';
import { createContext } from '@/server/context';
const handler = (request: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
// server/context.ts — 컨텍스트 생성
import { getServerSession } from 'next-auth';
export async function createContext({ req }: { req: Request }) {
const session = await getServerSession();
return {
userId: session?.user?.id,
userRole: session?.user?.role as 'admin' | 'user' | undefined,
};
}
// lib/trpc.ts — 클라이언트 설정
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';
export const trpc = createTRPCReact<AppRouter>();
// providers/TrpcProvider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
6. 클라이언트에서 완전한 타입 추론
// components/UserProfile.tsx
'use client';
import { trpc } from '@/lib/trpc';
export function UserProfile({ userId }: { userId: string }) {
// user 타입이 자동으로 추론돼 — import 없이!
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
if (!user) return null;
// user.name, user.email 등 자동완성 작동
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// components/CreateUserForm.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';
export function CreateUserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const createUser = trpc.user.create.useMutation({
onSuccess: (user) => {
// user 타입 자동 추론
console.log(`생성된 유저: ${user.id}`);
},
onError: (error) => {
// TRPC Error 코드 접근
if (error.data?.code === 'CONFLICT') {
alert('이미 사용 중인 이메일입니다');
}
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createUser.mutate({ name, email, password });
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="이름" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="이메일" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? '처리 중...' : '가입하기'}
</button>
</form>
);
}
백엔드에서 반환 필드명 하나를 바꾸면 → 클라이언트 코드에 즉시 TypeScript 에러. 런타임이 아니라 컴파일 타임에 잡아.
7. Zod로 에러 처리 강화
// 서버: 상세한 Zod 에러를 클라이언트에 전달
import { ZodError } from 'zod';
import { TRPCError } from '@trpc/server';
// tRPC는 Zod 에러를 자동으로 BAD_REQUEST로 변환하지만
// 직접 핸들링이 필요할 때
export const userRouter = router({
create: publicProcedure
.input(CreateUserSchema)
.mutation(async ({ input }) => {
try {
// 추가 비즈니스 로직 검증
const domainValidation = validateBusinessRules(input);
if (!domainValidation.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: domainValidation.message,
cause: new ZodError(domainValidation.issues),
});
}
return db.users.create(input);
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '유저 생성 중 오류가 발생했습니다',
cause: error,
});
}
}),
});
// 클라이언트: 필드별 에러 메시지 표시
function CreateUserFormWithErrors() {
const createUser = trpc.user.create.useMutation();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleSubmit = async (data: CreateUserInput) => {
try {
await createUser.mutateAsync(data);
} catch (error) {
if (error instanceof TRPCClientError) {
// Zod 에러에서 필드별 메시지 추출
const zodError = error.data?.zodError;
if (zodError?.fieldErrors) {
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(zodError.fieldErrors)) {
if (messages?.[0]) errors[field] = messages[0];
}
setFieldErrors(errors);
}
}
}
};
return (
<form>
<input name="email" />
{fieldErrors.email && <span className="text-red-500">{fieldErrors.email}</span>}
{fieldErrors.name && <span className="text-red-500">{fieldErrors.name}</span>}
</form>
);
}
8. REST에서 tRPC로 마이그레이션
기존 REST API를 tRPC로 완전히 바꾸기 어려울 수 있어. 점진적으로 마이그레이션하는 전략이 있어.
// 1단계: tRPC를 기존 REST 옆에 추가
// 새 기능은 tRPC로, 기존 기능은 REST 유지
// 2단계: REST 핸들러를 tRPC 프로시저로 래핑
// 기존 Express 라우트
// app.get('/api/users/:id', getUser);
// app.post('/api/users', createUser);
// tRPC로 래핑
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => getUser(input.id)), // 기존 함수 재사용
create: publicProcedure
.input(CreateUserSchema)
.mutation(({ input }) => createUser(input)), // 기존 함수 재사용
});
// 3단계: 클라이언트를 점진적으로 마이그레이션
// Before:
const user = await fetch(`/api/users/${id}`).then(r => r.json());
// After:
const user = await trpc.user.getById.query({ id });
9. tRPC가 적합한 경우 vs 적합하지 않은 경우
tRPC가 빛나는 경우
✅ 풀스택 TypeScript 모노레포 (Next.js, Remix, SvelteKit)
✅ 백엔드와 프론트엔드를 같은 팀이 개발
✅ 빠른 개발 속도가 중요한 스타트업
✅ API 스키마 문서화에 시간을 쓰고 싶지 않을 때
✅ React Query와 함께 쓸 때 (내장 통합)
tRPC가 맞지 않는 경우
❌ 외부 팀이나 서드파티 클라이언트가 API를 소비할 때
→ REST + OpenAPI (자동 문서화, 다양한 언어 클라이언트)
❌ 모바일 앱(Swift/Kotlin)이 같은 API를 씀
→ REST 또는 GraphQL이 더 나은 선택
❌ 마이크로서비스 간 통신
→ gRPC가 성능과 다언어 지원에서 우위
❌ 팀에 TypeScript 비숙련자가 있을 때
→ 타입 에러 해석이 어려울 수 있어
❌ 공개 API를 만들 때
→ REST + OpenAPI가 소비하기 쉬워
10. tRPC vs GraphQL vs REST 비교표
| 항목 | REST | GraphQL | tRPC |
|---|---|---|---|
| 타입 안전성 | 수동 (코드젠 필요) | 코드젠 필요 | 자동 (컴파일 타임) |
| 클라이언트 언어 | 모든 언어 | 모든 언어 | TypeScript 전용 |
| 러닝 커브 | 낮음 | 높음 | 중간 |
| 오버패칭 | 발생 가능 | 해결됨 | 발생 가능 |
| API 문서화 | OpenAPI | Introspection | 없음 (타입이 문서) |
| 번들 사이즈 영향 | 없음 | apollo-client 큼 | 작음 |
| 외부 소비 | 최적 | 좋음 | 어려움 |
| Next.js 통합 | 수동 | 추가 설정 | 네이티브 |
마무리
Zod + tRPC 조합의 핵심 가치를 한 줄로:
"런타임 검증(Zod)과 컴파일 타임 타입 추론(tRPC)이 합쳐지면, 백엔드 변경이 프론트엔드 타입 에러로 즉시 나타난다."
REST + TypeScript에서 발생하는 "타입은 맞는데 런타임 에러" 문제를 근본적으로 해결해.
단, 외부 API나 다언어 클라이언트가 있는 상황이라면 tRPC는 적합하지 않아. 그럴 때는 REST + Zod로 서버 측 검증만 도입해도 큰 효과를 볼 수 있어.
풀스택 TypeScript 프로젝트를 하고 있다면, tRPC는 진지하게 고려해볼 만한 선택이야.
Zod + tRPC: End-to-End Type-Safe API Design
1. The Hidden Lie of REST + TypeScript
TypeScript is supposed to give you type safety. Then you add REST APIs and the illusion cracks.
// Backend: Express + TypeScript
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
// Frontend: React + TypeScript
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
const fetchUser = async (id: string): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
return response.json() as User; // ← here's the lie
};
as User — that type assertion tricks the compiler.
A backend developer renames createdAt to created_at. No TypeScript error. Frontend builds fine. But at runtime, user.createdAt is undefined.
TypeError: Cannot read properties of undefined
at UserProfile.tsx:23
This is why you get runtime errors even with TypeScript: types exist only at compile time, and data arriving over the network gets no runtime validation.
2. Zod: Runtime Schema Validation
Zod is a TypeScript-first schema validation library. Define a schema once and get two things:
- Runtime validation: Verify actual data matches the schema
- Type inference: Automatically generate TypeScript types from the schema
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.string().datetime(),
age: z.number().int().min(0).max(150).optional(),
});
// Type inferred automatically
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; role: 'admin' | 'user' | 'moderator'; createdAt: string; age?: number }
// Runtime validation
const parseUser = (data: unknown): User => {
return UserSchema.parse(data); // throws ZodError on failure
};
// Or safely
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data.email); // typed as User
} else {
console.error(result.error.issues); // ZodError details
}
Useful Zod Patterns
// Nested objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2),
});
// Discriminated unions
const ResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: UserSchema }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
// Transforms: parse and convert
const DateFromStringSchema = z.string().transform((str) => new Date(str));
// Custom validation
const PasswordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number');
// Schema reuse
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });
3. What tRPC Is
tRPC is a TypeScript Remote Procedure Call library. Instead of REST endpoints, you define functions on the server and call them like local functions from the client.
Most importantly: server router types flow to the client automatically — no code generation required.
REST approach:
Backend: POST /users, manually document response type or create a shared types file
Frontend: Copy the types or import from shared package
→ Humans must keep these in sync
tRPC approach:
Backend: define function in router with Zod input schema
Frontend: import router type, get full inference
→ Backend change = immediate TypeScript error in the frontend
4. Setting Up a tRPC Router
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
interface Context {
userId?: string;
userRole?: 'admin' | 'user';
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// Auth middleware
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Login required' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
export const protectedProcedure = t.procedure.use(isAuthenticated);
// server/routers/user.ts
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
});
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: `User ${input.id} not found` });
}
return user; // return type inferred automatically
}),
list: protectedProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { page, limit, search } = input;
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
where: search ? { name: { contains: search } } : undefined,
});
return { users, total: await db.users.count() };
}),
create: publicProcedure
.input(CreateUserSchema)
.mutation(async ({ input }) => {
const existing = await db.users.findByEmail(input.email);
if (existing) {
throw new TRPCError({ code: 'CONFLICT', message: 'Email already in use' });
}
return db.users.create(input);
}),
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.userId !== input.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not authorized' });
}
return db.users.update(input);
}),
});
// server/router.ts
import { router } from './trpc';
import { userRouter } from './routers/user';
export const appRouter = router({ user: userRouter });
export type AppRouter = typeof appRouter; // Export the type for clients
5. Connecting to Next.js
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/router';
import { createContext } from '@/server/context';
const handler = (request: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';
export const trpc = createTRPCReact<AppRouter>();
6. Full Type Inference on the Client
'use client';
import { trpc } from '@/lib/trpc';
export function UserProfile({ userId }: { userId: string }) {
// `user` type is inferred automatically — no import needed
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
// user.name, user.email — autocomplete works
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
export function CreateUserForm() {
const createUser = trpc.user.create.useMutation({
onSuccess: (user) => {
console.log(`Created: ${user.id}`); // user type inferred
},
onError: (error) => {
if (error.data?.code === 'CONFLICT') {
alert('Email already in use');
}
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createUser.mutate({ name, email, password });
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Processing...' : 'Sign up'}
</button>
</form>
);
}
Change a field name on the backend → immediate TypeScript error in the frontend. Compile time, not runtime.
7. Error Handling with Zod
// tRPC automatically converts Zod validation errors to BAD_REQUEST
// For field-level errors on the client:
function CreateUserFormWithErrors() {
const createUser = trpc.user.create.useMutation();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleSubmit = async (data: CreateUserInput) => {
try {
await createUser.mutateAsync(data);
} catch (error) {
if (error instanceof TRPCClientError) {
const zodError = error.data?.zodError;
if (zodError?.fieldErrors) {
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(zodError.fieldErrors)) {
if (messages?.[0]) errors[field] = messages[0];
}
setFieldErrors(errors);
}
}
}
};
return (
<form onSubmit={handleSubmit}>
<input name="email" />
{fieldErrors.email && <span className="text-red-500">{fieldErrors.email}</span>}
</form>
);
}
8. Migrating from REST
You don't have to rewrite everything at once.
// Step 1: Add tRPC alongside existing REST
// New features in tRPC, old features stay as REST
// Step 2: Wrap existing REST handlers in tRPC procedures
// Before: app.get('/api/users/:id', getUser);
// After:
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => getUser(input.id)), // reuse existing function
});
// Step 3: Migrate clients gradually
// Before:
const user = await fetch(`/api/users/${id}`).then(r => r.json());
// After:
const user = await trpc.user.getById.query({ id });
9. When tRPC Fits (and When It Doesn't)
tRPC shines:
✅ Full-stack TypeScript monorepos (Next.js, Remix, SvelteKit)
✅ Same team owns both backend and frontend
✅ Startups prioritizing development speed
✅ When you want types to serve as documentation
✅ When using React Query (built-in integration)
tRPC isn't the right fit:
❌ External teams or third-party clients consume your API
→ REST + OpenAPI (auto-docs, multi-language clients)
❌ Mobile apps (Swift/Kotlin) use the same API
→ REST or GraphQL is more portable
❌ Microservice-to-microservice communication
→ gRPC wins on performance and multi-language support
❌ Building a public API
→ REST + OpenAPI is far easier to consume
10. tRPC vs GraphQL vs REST
| Aspect | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual (or codegen) | Requires codegen | Automatic at compile time |
| Client language | Any | Any | TypeScript only |
| Learning curve | Low | High | Medium |
| Over-fetching | Common | Solved | Possible |
| API documentation | OpenAPI | Introspection | Types are the docs |
| Bundle size impact | None | Large (Apollo) | Small |
| External consumption | Best | Good | Difficult |
| Next.js integration | Manual | Extra config | Native |