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».