О насБлогКонтакты
Backend Development17 апреля 2026 г. 11 мин 1

tRPC + Zod: типобезопасный fullstack без кодогенерации — практическое руководство

AunimedaAunimeda
📋 Содержание

tRPC + Zod: типобезопасный fullstack без кодогенерации — практическое руководство

Когда я впервые увидел tRPC — подумал: «очередная абстракция, которая через год умрёт». Это было три года назад. Сейчас у нас три production проекта на tRPC, и последний раз когда я писал REST endpoint — был для внешнего интеграционного API, куда доступ нужен третьим сторонам. Для внутреннего fullstack кода я к REST не возвращался.

Разберём как это работает на практике — не hello world, а реальный CRUD с авторизацией, JWT middleware, Zod валидацией и правильной обработкой ошибок.


Почему не REST + OpenAPI

Классическая боль fullstack TypeScript команды:

  1. Пишешь backend endpoint с типами
  2. Генеришь OpenAPI спеку (или пишешь руками)
  3. Запускаешь кодогенерацию для frontend (openapi-typescript, swagger-codegen)
  4. Импортируешь сгенерированные типы
  5. Меняешь 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 — посмотрите наши услуги или свяжитесь с нами.

Читайте также

Event-Driven Architecture в Node.js: Outbox Pattern, Kafka и гарантии доставкиaunimeda
Backend Development

Event-Driven Architecture в Node.js: Outbox Pattern, Kafka и гарантии доставки

Практическое руководство по надёжной event-driven архитектуре в Node.js: Outbox Pattern с PostgreSQL, Kafka с идемпотентностью, Saga для распределённых транзакций — с полным рабочим кодом.

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчикаaunimeda
Разработка

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчика

OWASP Top 10 — это стандарт критических рисков безопасности. SQL-инъекции, сломанный контроль доступа, SSRF — каждый пункт с реальной атакой на ваш Node.js/Next.js код и конкретным исправлением. Актуально для проектов на казахстанском рынке.

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшнaunimeda
Разработка

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшн

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm-пакеты. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки производительности, сравнение инструментов и конкретные рекомендации для казахстанских разработчиков.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Казахстане. Бесплатная консультация.

Получить консультацию Все статьи