При работе с Zod схема становится центральной точкой описания данных. Она одновременно:
В небольших проектах схемы часто хранятся рядом с кодом, который их использует. Однако по мере роста приложения возникают проблемы:
Грамотная организация схем позволяет превратить Zod в полноценный слой описания данных.
Минимальный вариант — размещение схем рядом с бизнес-логикой.
Пример структуры:
src/
├── users/
│ ├── user.service.ts
│ ├── user.controller.ts
│ └── user.schema.ts
Пример схемы:
// users/user.schema.ts
import { z } fr om "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2),
});
Преимущества:
Недостатки:
Во многих проектах используется единая директория:
src/
├── schemas/
│ ├── user.schema.ts
│ ├── post.schema.ts
│ └── auth.schema.ts
Подход удобен для:
Однако слишком крупный каталог схем превращается в «свалку» моделей. Поэтому обычно применяется модульная организация.
Наиболее масштабируемый подход — группировка по бизнес-доменам.
src/
├── modules/
│ ├── auth/
│ │ ├── schemas/
│ │ │ ├── login.schema.ts
│ │ │ ├── register.schema.ts
│ │ │ └── token.schema.ts
│ │ ├── auth.service.ts
│ │ └── auth.controller.ts
│ │
│ ├── users/
│ │ ├── schemas/
│ │ │ ├── user.schema.ts
│ │ │ ├── update-user.schema.ts
│ │ │ └── profile.schema.ts
Преимущества:
Одна из самых распространённых ошибок — использование одной схемы для всех задач.
Например:
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
password: z.string(),
createdAt: z.date(),
});
Такая схема редко подходит одновременно для:
Правильнее разделять схемы.
// create-user.schema.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2),
});
// update-user.schema.ts
import { z } from "zod";
import { CreateUserSchema } from "./create-user.schema";
export const UpdateUserSchema =
CreateUserSchema.partial();
// user-response.schema.ts
import { z } from "zod";
export const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.string(),
});
// user-entity.schema.ts
import { z } from "zod";
export const UserEntitySchema = z.object({
id: z.string(),
email: z.string(),
passwordHash: z.string(),
createdAt: z.date(),
});
Крупные схемы не должны создаваться как один огромный объект.
Плохой пример:
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
city: z.string(),
street: z.string(),
zip: z.string(),
avatar: z.string(),
role: z.string(),
permissions: z.array(z.string()),
});
Подход приводит к:
import { z } from "zod";
export const AddressSchema = z.object({
city: z.string(),
street: z.string(),
zip: z.string(),
});
export const AvatarSchema = z.object({
url: z.string().url(),
});
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
address: AddressSchema,
avatar: AvatarSchema,
});
const BaseUserSchema = z.object({
id: z.string(),
email: z.string(),
});
const AdminSchema = BaseUserSchema.extend({
permissions: z.array(z.string()),
});
Подход особенно полезен для:
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const UserSchema = BaseUserSchema.merge(
TimestampSchema
);
Часто повторяющиеся поля выносятся в отдельные схемы.
export const IdSchema = z.string().uuid();
export const TimestampsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
export const PaginationSchema = z.object({
page: z.number().int().positive(),
lim it: z.number().int().positive(),
});
При большом количестве схем полезно использовать barrel-файлы.
Структура:
schemas/
├── user/
│ ├── create-user.schema.ts
│ ├── update-user.schema.ts
│ ├── user-response.schema.ts
│ └── index.ts
// index.ts
export * from "./create-user.schema";
export * from "./update-user.schema";
export * from "./user-response.schema";
Использование:
import {
CreateUserSchema,
UpdateUserSchema,
} from "@/schemas/user";
Zod позволяет преобразовывать данные через
transform.
Это означает, что входной и выходной типы могут отличаться.
const UserSchema = z
.string()
.transform((value) => value.trim());
type Input = z.input<typeof UserSchema>;
type Output = z.output<typeof UserSchema>;
Для сложных систем важно явно разделять:
src/
├── modules/
│ ├── users/
│ │ ├── dto/
│ │ ├── schemas/
│ │ ├── services/
│ │ ├── repositories/
│ │ └── controllers/
Некоторые команды разделяют:
Например:
users/
├── dto/
│ └── create-user.dto.ts
│
├── schemas/
│ └── create-user.schema.ts
Однако в TypeScript-проектах DTO часто генерируются напрямую из Zod.
Одно из главных преимуществ Zod — отсутствие дублирования типов.
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
export type User = z.infer<typeof UserSchema>;
Важно хранить тип рядом со схемой.
В fullstack-приложениях часть схем используется:
packages/
├── shared/
│ ├── schemas/
│ │ ├── user.schema.ts
│ │ └── auth.schema.ts
Такие схемы используются одновременно:
Популярная структура:
packages/
├── api/
├── web/
├── mobile/
├── shared/
│ ├── schemas/
│ ├── types/
│ └── constants/
Схемы часто начинают импортировать друг друга.
Например:
// user.schema.ts
import { PostSchema } from "./post.schema";
// post.schema.ts
import { UserSchema } from "./user.schema";
Результат:
import { z } from "zod";
export const UserSchema = z.object({
posts: z.array(
z.lazy(() => PostSchema)
),
});
export const PostSchema = z.object({
author: z.lazy(() => UserSchema),
});
Плохой подход:
const UserSchema = z.object({
role: z.enum([
"admin",
"user",
"moderator",
]),
});
Лучше:
export const USER_ROLES = [
"admin",
"user",
"moderator",
] as const;
export const UserRoleSchema =
z.enum(USER_ROLES);
Преимущества:
В крупных проектах удобно выделять уровни.
Бизнес-сущности:
UserDomainSchema
REST/GraphQL DTO:
CreateUserRequestSchema
UserResponseSchema
Модели хранения:
UserEntitySchema
Единый стиль именования критически важен.
Рекомендуемые правила:
| Тип | Пример |
|---|---|
| Базовая схема | UserSchema |
| DTO создания | CreateUserSchema |
| DTO обновления | UpdateUserSchema |
| Ответ API | UserResponseSchema |
| Entity | UserEntitySchema |
| Input | UserInputSchema |
| Query | UserQuerySchema |
Во frontend-приложениях схемы часто связаны с формами.
features/
├── auth/
│ ├── forms/
│ │ ├── login.form.ts
│ │ ├── register.form.ts
│ │ └── schemas/
export const LoginFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
Иногда сложная бизнес-логика засоряет схему.
Плохой пример:
const schema = z.object({
password: z
.string()
.refine(hasUppercase)
.refine(hasNumber)
.refine(hasSpecialChar)
.refine(isNotCompromisedPassword)
});
Лучше выносить проверки:
export const passwordValidators = {
hasUppercase,
hasNumber,
hasSpecialChar,
};
Во многих проектах создаётся библиотека повторно используемых схем.
shared/
├── schemas/
│ ├── primitives/
│ ├── common/
│ ├── pagination/
│ ├── auth/
│ └── validation/
export const EmailSchema =
z.string().email();
export const UrlSchema =
z.string().url();
export const UUIDSchema =
z.string().uuid();
В разных слоях нужны разные правила обработки неизвестных полей.
const schema = z
.object({
name: z.string(),
})
.strict();
Неизвестные поля вызовут ошибку.
const schema = z
.object({
name: z.string(),
})
.passthrough();
Лишние поля сохраняются.
const schema = z
.object({
name: z.string(),
})
.strip();
Лишние поля удаляются.
В schema-first архитектуре схема становится основой всего приложения.
Из неё могут генерироваться:
При грамотной организации схем возможно автоматическое создание документации.
Например:
schemas/
├── requests/
├── responses/
└── entities/
Такое разделение особенно удобно для Swagger/OpenAPI-генерации.
Схемы должны тестироваться отдельно.
schemas/
├── user.schema.ts
└── __tests__/
└── user.schema.test.ts
describe("UserSchema", () => {
it("валидирует пользователя", () => {
const result = UserSchema.safeParse({
id: "123",
email: "test@test.com",
});
expect(result.success).toBe(true);
});
});
Не стоит смешивать:
Плохой пример:
z.string().refine(async (email) => {
return !(await userExists(email));
});
Лучше:
Иногда схема зависит от параметров.
export const createPasswordSchema = (
minLength: number
) =>
z.string().min(minLength);
export const createUserSchema = (
isAdmin: boolean
) =>
z.object({
name: z.string(),
permissions: isAdmin
? z.array(z.string())
: z.undefined(),
});
Полезно хранить сообщения централизованно.
export const validationMessages = {
required: "Поле обязательно",
invalidEmail: "Некорректный email",
};
Использование:
z.string({
required_error:
validationMessages.required,
});
При росте проекта рекомендуется: