tRPC + Zod: типобезопасный fullstack без кодогенерации — практическое руководство
Когда я впервые увидел tRPC — подумал: «очередная абстракция, которая через год умрёт». Это было три года назад. Сейчас у нас три production проекта на tRPC, и последний раз когда я писал REST endpoint — был для внешнего интеграционного API, куда доступ нужен третьим сторонам. Для внутреннего fullstack кода я к REST не возвращался.
Разберём как это работает на практике — не hello world, а реальный CRUD с авторизацией, JWT middleware, Zod валидацией и правильной обработкой ошибок.
Почему не REST + OpenAPI
Классическая боль fullstack TypeScript команды:
- Пишешь backend endpoint с типами
- Генеришь OpenAPI спеку (или пишешь руками)
- Запускаешь кодогенерацию для frontend (
openapi-typescript,swagger-codegen) - Импортируешь сгенерированные типы
- Меняешь endpoint — повторяешь шаги 2-4
В реальной работе шаги 2-4 ломаются постоянно. Забыли запустить кодогенерацию перед деплоем — frontend падает с runtime ошибкой, хотя TypeScript компилятор молчал. Мы так однажды положили форму оплаты у клиента на 40 минут — поле amount переименовали в totalAmount, кодогенерацию не запустили, типы на фронте были старые.
tRPC решает это кардинально: типы шарятся напрямую через TypeScript, без генерации. Frontend буквально импортирует тип роутера с backend и получает полную типизацию вызовов.
Установка и структура проекта
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
npm install -D @types/node
Структура которую мы используем в monorepo:
apps/
web/ # Next.js frontend
api/ # или server/ для отдельного сервера
packages/
trpc/
src/
routers/
tasks.ts
auth.ts
index.ts # appRouter
context.ts
trpc.ts # инициализация
middleware/
auth.ts
Если у вас не monorepo — можно держать всё в src/server/trpc/. Главное чтобы frontend мог импортировать AppRouter тип без circular dependencies.
Инициализация tRPC
// packages/trpc/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson, // поддержка Date, Map, Set без ручной сериализации
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
superjson — важная деталь. Без него Date объекты приходят на frontend как строки, и вы либо руками конвертируете везде, либо получаете баги. С superjson — прозрачно.
Context: откуда берётся информация о пользователе
Context создаётся для каждого запроса и доступен во всех процедурах:
// packages/trpc/src/context.ts
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { verifyJWT } from './utils/jwt';
import { db } from './db';
export type User = {
id: string;
email: string;
role: 'user' | 'admin';
};
export type Context = {
user: User | null;
db: typeof db;
};
export async function createContext(
opts: CreateNextContextOptions
): Promise<Context> {
const token = opts.req.headers.authorization?.replace('Bearer ', '');
let user: User | null = null;
if (token) {
try {
const payload = verifyJWT(token);
// Можно добавить проверку в Redis (revoked tokens)
user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
} catch {
// Токен невалидный — user остаётся null
// Не бросаем ошибку здесь, пусть middleware решает
}
}
return { user, db };
}
Важный момент: мы не бросаем ошибку в createContext при невалидном токене. Пусть это делает auth middleware — потому что есть публичные роуты, которые работают без токена.
Auth Middleware
// packages/trpc/src/middleware/auth.ts
import { TRPCError } from '@trpc/server';
import { middleware, publicProcedure } from '../trpc';
import type { User } from '../context';
const isAuthenticated = middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Необходима авторизация',
});
}
return next({
ctx: {
...ctx,
user: ctx.user, // TypeScript теперь знает что user не null
},
});
});
const isAdmin = middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
if (ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Требуются права администратора',
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Экспортируем готовые процедуры с применёнными middleware
export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = publicProcedure.use(isAdmin);
После применения isAuthenticated TypeScript знает что ctx.user не null — это работает через TypeScript narrowing. Если попытаетесь в protectedProcedure обратиться к ctx.user?.id — компилятор скажет, что ?. не нужен.
Zod схемы как единственный источник истины
// packages/trpc/src/schemas/task.ts
import { z } from 'zod';
export const TaskStatus = z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED']);
export type TaskStatus = z.infer<typeof TaskStatus>;
export const CreateTaskSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(200, 'Максимум 200 символов'),
description: z.string().max(2000).optional(),
status: TaskStatus.default('TODO'),
dueDate: z.date().min(new Date(), 'Дата не может быть в прошлом').optional(),
assigneeId: z.string().uuid().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
tags: z.array(z.string().max(50)).max(10).default([]),
});
export const UpdateTaskSchema = CreateTaskSchema.partial().extend({
id: z.string().uuid(),
});
export const TaskFiltersSchema = z.object({
status: TaskStatus.optional(),
assigneeId: z.string().uuid().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
search: z.string().max(100).optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
});
// Тип для создания — используется и на backend и на frontend
export type CreateTaskInput = z.infer<typeof CreateTaskSchema>;
export type UpdateTaskInput = z.infer<typeof UpdateTaskSchema>;
export type TaskFilters = z.infer<typeof TaskFiltersSchema>;
Та же схема используется:
- В tRPC процедуре для валидации входных данных
- В React Hook Form на frontend (
zodResolver) - Для генерации типов БД (через Prisma или ручная синхронизация)
Один источник истины — никаких рассинхронизаций.
Tasks Router: полный CRUD
// packages/trpc/src/routers/tasks.ts
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router } from '../trpc';
import { protectedProcedure } from '../middleware/auth';
import {
CreateTaskSchema,
UpdateTaskSchema,
TaskFiltersSchema,
} from '../schemas/task';
export const tasksRouter = router({
// GET /tasks — список с фильтрацией и пагинацией
list: protectedProcedure
.input(TaskFiltersSchema)
.query(async ({ ctx, input }) => {
const { page, limit, status, assigneeId, search, priority } = input;
const offset = (page - 1) * limit;
const where = {
// Пользователь видит только свои задачи (или задачи команды)
OR: [
{ createdById: ctx.user.id },
{ assigneeId: ctx.user.id },
],
...(status && { status }),
...(assigneeId && { assigneeId }),
...(priority && { priority }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}),
};
const [tasks, total] = await Promise.all([
ctx.db.task.findMany({
where,
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
assignee: { select: { id: true, name: true, email: true } },
createdBy: { select: { id: true, name: true } },
},
}),
ctx.db.task.count({ where }),
]);
return {
tasks,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}),
// GET /tasks/:id
byId: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const task = await ctx.db.task.findUnique({
where: { id: input.id },
include: {
assignee: true,
createdBy: { select: { id: true, name: true, email: true } },
comments: {
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true, name: true } } },
},
},
});
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Задача ${input.id} не найдена`,
});
}
// Проверяем доступ
const hasAccess =
task.createdById === ctx.user.id ||
task.assigneeId === ctx.user.id ||
ctx.user.role === 'admin';
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Нет доступа к этой задаче',
});
}
return task;
}),
// POST /tasks
create: protectedProcedure
.input(CreateTaskSchema)
.mutation(async ({ ctx, input }) => {
const task = await ctx.db.task.create({
data: {
...input,
createdById: ctx.user.id,
},
include: {
assignee: { select: { id: true, name: true } },
},
});
return task;
}),
// PATCH /tasks/:id
update: protectedProcedure
.input(UpdateTaskSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// Проверяем существование и права
const existing = await ctx.db.task.findUnique({ where: { id } });
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existing.createdById !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return ctx.db.task.update({
where: { id },
data,
include: {
assignee: { select: { id: true, name: true } },
},
});
}),
// DELETE /tasks/:id
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.task.findUnique({
where: { id: input.id },
});
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existing.createdById !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await ctx.db.task.delete({ where: { id: input.id } });
return { success: true };
}),
});
App Router и Next.js интеграция
// packages/trpc/src/routers/index.ts
import { router } from '../trpc';
import { tasksRouter } from './tasks';
import { authRouter } from './auth';
export const appRouter = router({
tasks: tasksRouter,
auth: authRouter,
});
export type AppRouter = typeof appRouter;
// apps/web/src/pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '@myapp/trpc';
import { createContext } from '@myapp/trpc/context';
export default createNextApiHandler({
router: appRouter,
createContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(`tRPC ошибка на ${path ?? '<unknown>'}:`, error);
}
: undefined,
});
Frontend: React Query интеграция
// apps/web/src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@myapp/trpc';
export const trpc = createTRPCReact<AppRouter>();
// apps/web/src/components/TaskList.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export function TaskList() {
const [filters, setFilters] = useState({ page: 1, limit: 20 });
// TypeScript знает точный тип возвращаемых данных
const { data, isLoading, error } = trpc.tasks.list.useQuery(filters, {
staleTime: 30_000,
keepPreviousData: true, // плавная пагинация
});
const utils = trpc.useUtils();
const createTask = trpc.tasks.create.useMutation({
onSuccess: () => {
// Инвалидируем кэш списка после создания
utils.tasks.list.invalidate();
},
});
const deleteTask = trpc.tasks.delete.useMutation({
onMutate: async ({ id }) => {
// Оптимистичное обновление
await utils.tasks.list.cancel();
const prev = utils.tasks.list.getData(filters);
utils.tasks.list.setData(filters, (old) => {
if (!old) return old;
return {
...old,
tasks: old.tasks.filter((t) => t.id !== id),
};
});
return { prev };
},
onError: (_err, _vars, ctx) => {
// Откат при ошибке
if (ctx?.prev) {
utils.tasks.list.setData(filters, ctx.prev);
}
},
});
if (isLoading) return <div>Загрузка...</div>;
// error имеет тип TRPCClientError<AppRouter> — знаем коды ошибок
if (error) {
if (error.data?.code === 'UNAUTHORIZED') {
return <div>Войдите в систему</div>;
}
return <div>Ошибка: {error.message}</div>;
}
return (
<div>
{data?.tasks.map((task) => (
<div key={task.id}>
{/* task.assignee.name — TypeScript знает что это string | null */}
<h3>{task.title}</h3>
<span>{task.assignee?.name ?? 'Не назначен'}</span>
<button onClick={() => deleteTask.mutate({ id: task.id })}>
Удалить
</button>
</div>
))}
</div>
);
}
Как TypeScript ловит ошибки на compile time
Вот реальный сценарий: переименовали поле в схеме.
// БЫЛО:
export const CreateTaskSchema = z.object({
title: z.string(),
dueDate: z.date().optional(),
});
// СТАЛО — переименовали dueDate в deadline:
export const CreateTaskSchema = z.object({
title: z.string(),
deadline: z.date().optional(), // <-- изменили
});
На frontend сразу подсвечивается ошибка:
createTask.mutate({
title: 'Подготовить отчёт',
dueDate: new Date('2026-05-01'), // ❌ TS Error: Object literal may only specify known properties,
// and 'dueDate' does not exist in type
});
Без tRPC эта ошибка уехала бы в production, упала в runtime — и вы бы искали её через Sentry логи. С tRPC — красная подсветка в IDE до коммита.
Обработка ошибок: TRPCError коды
// Все доступные коды:
// PARSE_ERROR, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN,
// NOT_FOUND, METHOD_NOT_SUPPORTED, TIMEOUT, CONFLICT,
// PRECONDITION_FAILED, PAYLOAD_TOO_LARGE, INTERNAL_SERVER_ERROR
// На клиенте:
try {
await trpc.tasks.create.mutate(input);
} catch (err) {
if (err instanceof TRPCClientError) {
switch (err.data?.code) {
case 'BAD_REQUEST':
// Zod ошибки — показываем пользователю
const fieldErrors = err.data.zodError?.fieldErrors;
// fieldErrors.title → ['Название обязательно']
break;
case 'UNAUTHORIZED':
router.push('/login');
break;
case 'FORBIDDEN':
toast.error('Нет прав для этого действия');
break;
}
}
}
Что мы не учли поначалу и поплатились
Батчинг запросов. По умолчанию tRPC объединяет несколько useQuery вызовов в один HTTP запрос. Это хорошо для перформанса, но ломается если у вас rate limiting на nginx настроен по числу запросов — вместо 5 запросов приходит 1 большой, но nginx его пропускал, а потом мы подняли rate limit и поломали батчинг. Решение: настраивайте rate limiting на размер тела запроса, а не на количество.
Subscription через WebSocket. Если нужны realtime обновления — tRPC поддерживает subscriptions через WebSocket адаптер. Мы добавили это через три месяца после запуска, и пришлось переписать context creation — учитывайте это с начала, если нужен realtime.
Размер бандла. @trpc/client сам по себе легкий, но если тащить весь appRouter тип на frontend — в некоторых сетапах это добавляет в бандл. Используйте type-only импорты: import type { AppRouter } from '...'.
Итого: когда tRPC имеет смысл
tRPC — правильный выбор если:
- Монорепо или fullstack Next.js проект с TypeScript на обоих концах
- Команда до 10 человек, нет отдельных frontend/backend команд
- Не нужен публичный API для третьих сторон
Не стоит использовать если:
- Нужен публичный REST API (мобильные клиенты, партнёры)
- Backend на другом языке (Go, Python)
- Микросервисная архитектура с разными командами
Для нас tRPC дал главное — уверенность при рефакторинге. Переименовать поле, изменить тип, удалить эндпоинт — TypeScript покажет все места где нужно обновить код, до деплоя.
Разрабатываете fullstack TypeScript продукт и хотите профессиональную архитектуру без лишних слоёв? Aunimeda — посмотрите наши услуги или свяжитесь с нами.