tRPCMiddleCoding

Как добавить валидацию входных данных к процедурам tRPC с помощью Zod?

Валидация входных данных добавляется через .input(zodSchema) на builder-е процедуры. tRPC автоматически проверяет входные данные и бросает BAD_REQUEST при несоответствии схеме.

Валидация входных данных с Zod в tRPC

tRPC нативно интегрируется с Zod: схема, переданная в .input(), автоматически валидирует входные данные до вызова resolver-функции. При ошибке валидации клиент получает структурированную ошибку с кодом BAD_REQUEST.

Базовые примеры

// server/routers/post.ts
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';

export const postRouter = router({
  // Query с примитивным входом
  getById: publicProcedure
    .input(z.string().uuid('Invalid UUID format'))
    .query(async ({ input, ctx }) => {
      // input типизирован как string
      return ctx.db.post.findUnique({ where: { id: input } });
    }),

  // Query с объектом и дефолтными значениями
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().int().min(1).max(100).default(20),
        cursor: z.string().uuid().optional(),
        search: z.string().max(200).optional(),
      })
    )
    .query(async ({ input, ctx }) => {
      // input.limit — number (не undefined)
      // input.cursor — string | undefined
      const { limit, cursor, search } = input;
      return ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: search ? { title: { contains: search } } : undefined,
      });
    }),

  // Mutation со сложной схемой
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, 'Title is required').max(200),
        body: z.string().min(10, 'Body must be at least 10 characters'),
        tags: z.array(z.string()).max(10).default([]),
        publishedAt: z.date().optional(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Переиспользование схем

// shared/schemas/post.ts — можно импортировать и на клиенте
import { z } from 'zod';

export const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(10),
  tags: z.array(z.string()).max(10).default([]),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;

// server/routers/post.ts
import { createPostSchema } from '@/shared/schemas/post';

export const postRouter = router({
  create: protectedProcedure
    .input(createPostSchema) // переиспользуем схему
    .mutation(({ input }) => { /* ... */ }),
});

// client/components/PostForm.tsx — та же схема для react-hook-form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createPostSchema, type CreatePostInput } from '@/shared/schemas/post';

export function PostForm() {
  const form = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema), // одна схема везде
  });
}

Обработка ошибок валидации на клиенте

// client/utils/trpc-errors.ts
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';

export function getValidationErrors(
  err: unknown
): Record<string, string> | null {
  if (err instanceof TRPCClientError<AppRouter>) {
    const zodError = err.data?.zodError;
    if (zodError?.fieldErrors) {
      // { title: ['Title is required'], body: ['...'] }
      return Object.fromEntries(
        Object.entries(zodError.fieldErrors).map(([k, v]) => [k, v?.[0] ?? ''])
      );
    }
  }
  return null;
}

Продвинутые возможности

// Трансформация входных данных
const createUserSchema = z.object({
  email: z.string().email().toLowerCase().trim(), // трансформация
  age: z.string().transform(Number).pipe(z.number().min(18)), // строка → число
});

// Дискриминированные объединения
const filterSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('date'), from: z.date(), to: z.date() }),
  z.object({ type: z.literal('text'), query: z.string() }),
]);

Подводные камни

  • Zod-ошибки преобразуются в BAD_REQUEST автоматически: tRPC перехватывает ZodError — не нужно оборачивать вручную в TRPCError.
  • Без errorFormatter zodError недоступен на клиенте: нужно настроить errorFormatter в initTRPC, чтобы err.data.zodError передавался клиенту.
  • Date из JSON приходит как строка: без superjson-трансформера z.date() не разберёт ISO-строку автоматически — используйте z.coerce.date() или superjson.
  • default() влияет на тип: поле с .default([]) в TypeScript-типе не будет undefined, что может удивить при сравнении с OpenAPI-схемой.
  • Валидация не заменяет серверные проверки прав: Zod проверяет форму данных, но не бизнес-правила — проверку «пользователь не может редактировать чужой пост» делайте в процедуре.
  • z.infer с .default() и .optional(): входной тип (z.input) и выходной (z.infer) могут отличаться — используйте z.input<typeof schema> для типа формы и z.infer<typeof schema> для типа после парсинга.
  • Производительность на больших массивах: z.array(complexSchema) на тысячах элементов может заметно тормозить — рассмотрите пагинацию или упрощение схемы.

Common mistakes

  • Смешивать «валидация input через Zod» с похожим механизмом без критерия выбора.
  • Игнорировать риск: неверно оценить границы применения темы «валидация input через Zod» и получить хрупкое решение.
  • Показывать только синтаксис и не объяснять поведение в runtime или сборке.

What the interviewer is testing

  • Объясняет runtime-проверка пользовательских данных перед resolver.
  • Показывает на примере, как работает: TypeScript типы исчезают в runtime, поэтому tRPC procedure должна валидировать input через schema parser; Zod дает и runtime validation, и inferred TypeScript тип.
  • Называет production-нюанс и граничный случай для темы «валидация input через Zod».

Sources

Related topics