
Zod + tRPC: 엔드투엔드 타입 안전한 API 설계
REST API와 수동 타입 관리의 한계를 Zod 스키마 검증과 tRPC로 해결하는 방법을 정리했어. 라우터 설정부터 클라이언트 타입 추론, Zod 에러 처리, REST 마이그레이션까지 실전 예제로 커버해.

REST API와 수동 타입 관리의 한계를 Zod 스키마 검증과 tRPC로 해결하는 방법을 정리했어. 라우터 설정부터 클라이언트 타입 추론, Zod 에러 처리, REST 마이그레이션까지 실전 예제로 커버해.
API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.

any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

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를 쓰는데 런타임 에러가 나는 이유야. 타입은 컴파일 타임에만 존재하고, 네트워크를 통해 오는 데이터는 런타임에 검증을 안 해.
Zod는 TypeScript-first 스키마 검증 라이브러리야. 스키마를 정의하면 두 가지를 동시에 얻어:
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 접근
}
// 중첩 객체
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 });
tRPC는 TypeScript Remote Procedure Call 라이브러리야. REST 대신 함수를 직접 호출하는 것처럼 API를 작성하고 호출할 수 있게 해줘.
그리고 가장 중요한 건: 별도의 코드 생성 없이 서버 라우터의 타입이 자동으로 클라이언트에 추론돼.
REST 방식:
백엔드: POST /users, 응답 타입 문서화 또는 수동 타입 파일 작성
프론트엔드: 백엔드 타입 복사하거나 공유 패키지에서 import
→ 두 파일이 항상 동기화된 상태를 유지해야 해 (사람이)
tRPC 방식:
백엔드: router에 함수 정의, Zod 입력 스키마 첨부
프론트엔드: router 타입을 import해서 자동 추론
→ 백엔드가 바뀌면 프론트엔드에 즉시 TypeScript 에러
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;
// 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>
);
}
// 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 에러. 런타임이 아니라 컴파일 타임에 잡아.
// 서버: 상세한 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>
);
}
기존 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 });
✅ 풀스택 TypeScript 모노레포 (Next.js, Remix, SvelteKit)
✅ 백엔드와 프론트엔드를 같은 팀이 개발
✅ 빠른 개발 속도가 중요한 스타트업
✅ API 스키마 문서화에 시간을 쓰고 싶지 않을 때
✅ React Query와 함께 쓸 때 (내장 통합)
❌ 외부 팀이나 서드파티 클라이언트가 API를 소비할 때
→ REST + OpenAPI (자동 문서화, 다양한 언어 클라이언트)
❌ 모바일 앱(Swift/Kotlin)이 같은 API를 씀
→ REST 또는 GraphQL이 더 나은 선택
❌ 마이크로서비스 간 통신
→ gRPC가 성능과 다언어 지원에서 우위
❌ 팀에 TypeScript 비숙련자가 있을 때
→ 타입 에러 해석이 어려울 수 있어
❌ 공개 API를 만들 때
→ REST + OpenAPI가 소비하기 쉬워
| 항목 | REST | GraphQL | tRPC |
|---|---|---|---|
| 타입 안전성 | 수동 (코드젠 필요) | 코드젠 필요 | 자동 (컴파일 타임) |
| 클라이언트 언어 | 모든 언어 | 모든 언어 | TypeScript 전용 |
| 러닝 커브 | 낮음 | 높음 | 중간 |
| 오버패칭 | 발생 가능 | 해결됨 | 발생 가능 |
| API 문서화 | OpenAPI | Introspection | 없음 (타입이 문서) |
| 번들 사이즈 영향 | 없음 | apollo-client 큼 | 작음 |
| 외부 소비 | 최적 | 좋음 | 어려움 |
| Next.js 통합 | 수동 | 추가 설정 | 네이티브 |
Zod + tRPC 조합의 핵심 가치를 한 줄로:
"런타임 검증(Zod)과 컴파일 타임 타입 추론(tRPC)이 합쳐지면, 백엔드 변경이 프론트엔드 타입 에러로 즉시 나타난다."
REST + TypeScript에서 발생하는 "타입은 맞는데 런타임 에러" 문제를 근본적으로 해결해.
단, 외부 API나 다언어 클라이언트가 있는 상황이라면 tRPC는 적합하지 않아. 그럴 때는 REST + Zod로 서버 측 검증만 도입해도 큰 효과를 볼 수 있어.
풀스택 TypeScript 프로젝트를 하고 있다면, tRPC는 진지하게 고려해볼 만한 선택이야.
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.
Zod is a TypeScript-first schema validation library. Define a schema once and get two things:
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
}
// 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 });
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
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
// 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>();
'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.
// 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>
);
}
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 });
✅ 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
| 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 |