TS_Fullstack_Shared_Types_Zod
TS Fullstack: shared types и Zod
Fullstack-типизация — подход, при котором frontend и backend используют согласованные контракты данных. TypeScript проверяет код во время разработки, но не проверяет JSON во время выполнения. Поэтому общие типы удобно дополнять Zod-схемами.
TypeScript → проверка кода при разработке
Zod → проверка данных во время выполнения
Контракт → единое описание запросов и ответовОбщие типы между frontend и backend
Без общего контракта сервер может вернуть одну структуру, а клиент — ожидать другую:
// Сервер фактически возвращает:
{ id: "user-1", fullName: "Анна" }
// Клиент ожидает:
type User = {
id: number;
name: string;
};Код клиента может скомпилироваться, но во время выполнения получить неверные значения или undefined.
Общий DTO описывается один раз:
export type UserDto = {
id: string;
fullName: string;
createdAt: string;
};Затем импортируется обеими сторонами:
import type { UserDto } from "@app/contracts";Общие контракты могут описывать:
- тело запроса;
- параметры маршрута и query-параметры;
- успешные ответы;
- ошибки API;
- пагинацию и фильтры;
- события WebSocket и сообщения очередей.
DTO и внутренние модели
DTO не обязан совпадать с моделью базы данных:
type UserRecord = {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
};
export type UserDto = {
id: string;
email: string;
createdAt: string;
};
function toUserDto(user: UserRecord): UserDto {
return {
id: user.id,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
}Отдельный DTO предотвращает случайную отправку passwordHash и явно определяет формат даты. В JSON объект Date превращается в строку.
Где хранить контракты
В небольшом проекте достаточно директории shared:
project/
├── client/
├── server/
└── shared/
├── users.ts
└── api.tsВ крупном проекте удобно создать пакет:
project/
├── apps/
│ ├── web/
│ └── api/
└── packages/
└── contracts/
├── src/
│ ├── users.ts
│ └── index.ts
└── package.jsonВ пакет контрактов стоит помещать DTO, Zod-схемы, форматы ошибок и общие перечисления. Не следует помещать React-компоненты, ORM-модели, доступ к базе, fs, секреты и серверные сервисы.
Зачем нужен Zod
TypeScript-типы удаляются при компиляции:
type User = {
id: string;
};Этот тип не способен проверить ответ сервера. Утверждение типа также ничего не валидирует:
// Небезопасно: as только подавляет сомнения компилятора.
const user = (await response.json()) as User;Zod создаёт runtime-схему, которая реально проверяет значение.
Установка:
npm install zodimport { z } from "zod";
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
createdAt: z.string().datetime(),
});Вывод типа из схемы
Схема становится источником истины, а TypeScript-тип выводится через z.infer:
export type User = z.infer<typeof userSchema>;Не нужно отдельно поддерживать одинаковые type User и userSchema: они могут разойтись после рефакторинга.
Основные схемы Zod
const schema = z.object({
title: z.string(),
price: z.number(),
available: z.boolean(),
value: z.null(),
anything: z.unknown(),
});z.unknown() безопаснее z.any(): неизвестное значение нельзя использовать без проверки.
Строки и числа
const emailSchema = z.string().trim().toLowerCase().email();
const idSchema = z.string().uuid();
const urlSchema = z.string().url();
const nameSchema = z.string().trim().min(2).max(100);
const quantitySchema = z.number().int().positive();
const percentSchema = z.number().min(0).max(100);Массивы и перечисления
const tagsSchema = z.array(z.string()).min(1).max(10);
const roleSchema = z.enum(["user", "editor", "admin"]);
type Role = z.infer<typeof roleSchema>;
// "user" | "editor" | "admin"Необязательные и nullable-поля
const profileSchema = z.object({
nickname: z.string().optional(), // string | undefined
avatar: z.string().url().nullable(), // string | null
bio: z.string().nullish(), // string | null | undefined
});Производные объектные схемы
const productSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
price: z.number().nonnegative(),
description: z.string().optional(),
});
const createProductSchema = productSchema.omit({ id: true });
const productPreviewSchema = productSchema.pick({
id: true,
title: true,
price: true,
});
const updateProductSchema = createProductSchema.partial();
const productWithStockSchema = productSchema.extend({
stock: z.number().int().nonnegative(),
});Для PATCH часто нужно запретить пустой объект:
const patchProductSchema = createProductSchema
.partial()
.refine((data) => Object.keys(data).length > 0, {
message: "Передайте хотя бы одно поле",
});parse() и safeParse()
parse() возвращает проверенные данные или выбрасывает ZodError:
const user = userSchema.parse(input);Это удобно, если исключение обрабатывает общий try/catch или централизованный обработчик ошибок.
safeParse() возвращает дискриминируемое объединение и не выбрасывает исключение:
const result = userSchema.safeParse(input);
if (!result.success) {
console.error(result.error.issues);
} else {
const user: User = result.data;
console.log(user.email);
}В обработчиках пользовательского ввода safeParse() обычно удобнее, потому что ошибка валидации является ожидаемым результатом.
Форматирование ошибок
function formatErrors(error: z.ZodError) {
return error.issues.reduce<Record<string, string[]>>(
(fields, issue) => {
const path = issue.path.join(".") || "root";
fields[path] ??= [];
fields[path].push(issue.message);
return fields;
},
{},
);
}Пример ответа API:
{
"ok": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Некорректные данные",
"fields": {
"email": ["Некорректный email"]
}
}
}Преобразования и дополнительные проверки
transform()
const slugSchema = z.string().transform((value) =>
value.trim().toLowerCase().replaceAll(" ", "-"),
);Входной и выходной типы могут различаться:
const lengthSchema = z.string().transform((value) => value.length);
type Input = z.input<typeof lengthSchema>; // string
type Output = z.output<typeof lengthSchema>; // numberz.infer соответствует выходному типу.
Coercion
Query-параметры и переменные окружения обычно являются строками:
const querySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});С z.coerce.boolean() нужна осторожность: JavaScript превращает любую непустую строку, включая "false", в true. Строгий вариант:
const booleanQuerySchema = z
.enum(["true", "false"])
.transform((value) => value === "true");refine()
const registrationSchema = z
.object({
password: z.string().min(8),
passwordConfirmation: z.string(),
})
.refine(
(data) => data.password === data.passwordConfirmation,
{
path: ["passwordConfirmation"],
message: "Пароли не совпадают",
},
);Асинхронные проверки выполняются через parseAsync() или safeParseAsync(). Структурную валидацию лучше отделять от бизнес-правил: формат email проверяет Zod, а уникальность email — сервис или база данных.
Типобезопасные API-контракты
Контракт запроса и ответа можно описать в общем пакете:
import { z } from "zod";
export const createUserBodySchema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().toLowerCase().email(),
});
export type CreateUserBody = z.infer<typeof createUserBodySchema>;
export const userSchema = createUserBodySchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof userSchema>;
export const createUserResponseSchema = z.object({
data: userSchema,
});
export type CreateUserResponse = z.infer<
typeof createUserResponseSchema
>;
export const createUserContract = {
method: "POST",
path: "/api/users",
body: createUserBodySchema,
response: createUserResponseSchema,
} as const;as const сохраняет литеральные типы "POST" и "/api/users".
Общий формат результата
const apiErrorSchema = z.object({
code: z.string(),
message: z.string(),
fields: z.record(z.string(), z.array(z.string())).optional(),
});
const createApiResultSchema = <T extends z.ZodTypeAny>(data: T) =>
z.discriminatedUnion("ok", [
z.object({ ok: z.literal(true), data }),
z.object({ ok: z.literal(false), error: apiErrorSchema }),
]);Поле ok позволяет TypeScript безопасно сузить тип результата.
Валидация на backend
Пример Express-обработчика:
import express from "express";
import {
createUserBodySchema,
type CreateUserResponse,
} from "@app/contracts";
const app = express();
app.use(express.json());
app.post("/api/users", async (req, res) => {
const result = createUserBodySchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "Некорректные данные",
fields: result.error.flatten().fieldErrors,
},
});
return;
}
const user = await userService.create(result.data);
const response = {
data: user,
} satisfies CreateUserResponse;
res.status(201).json(response);
});После успешной проверки result.data уже типизирован и может содержать нормализованные значения.
Параметры маршрута и query тоже следует проверять:
const paramsSchema = z.object({
userId: z.string().uuid(),
});
const usersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(["user", "editor", "admin"]).optional(),
});Клиентская валидация улучшает интерфейс, но backend обязан повторно проверять данные: запрос можно отправить без пользовательского интерфейса.
Валидация ответа на frontend
Аннотация не проверяет реальный ответ:
const data: User = await response.json(); // небезопасноНадёжнее считать JSON неизвестным значением:
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const json: unknown = await response.json();
return userSchema.parse(json);
}fetch() не отклоняет Promise при статусах 404 или 500, поэтому response.ok необходимо проверять отдельно.
Общая функция клиента:
async function apiRequest<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit,
): Promise<T> {
const response = await fetch(url, options);
const json: unknown = await response.json();
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return schema.parse(json);
}Использование:
const result = await apiRequest(
"/api/users",
createUserResponseSchema,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Анна",
email: "anna@example.com",
} satisfies CreateUserBody),
},
);Runtime-проверка особенно полезна для сторонних API, независимо развёртываемых приложений и критичных данных.
Zod и React Hook Form
Одна схема может проверять форму и серверный запрос:
npm install react-hook-form @hookform/resolvers zodimport { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
createUserBodySchema,
type CreateUserBody,
} from "@app/contracts";
function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateUserBody>({
resolver: zodResolver(createUserBodySchema),
defaultValues: { name: "", email: "" },
});
async function onSubmit(data: CreateUserBody) {
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} />
{errors.name && <p role="alert">{errors.name.message}</p>}
<input type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<button disabled={isSubmitting}>Создать</button>
</form>
);
}Если модель формы отличается от API — например, содержит passwordConfirmation — лучше создать отдельную схему формы и явно преобразовать результат в DTO запроса.
Монорепозиторий
Монорепозиторий содержит несколько приложений и пакетов в одном репозитории:
fullstack-project/
├── apps/
│ ├── web/ # React
│ └── api/ # Node.js / Express
├── packages/
│ ├── contracts/ # Zod-схемы и DTO
│ ├── eslint-config/
│ └── tsconfig/
├── package.json
└── pnpm-workspace.yamlpnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"Приложения подключают локальный пакет:
{
"dependencies": {
"@app/contracts": "workspace:*"
}
}Преимущества монорепозитория:
- frontend, backend и контракт меняются в одном pull request;
- общие пакеты не обязательно публиковать;
- проще выполнять сквозной рефакторинг;
- можно централизовать проверки и конфигурацию.
Сложности:
- требуется контроль зависимостей между пакетами;
- важны кеширование и порядок сборки;
- нужно учитывать независимое развёртывание приложений;
- нельзя допускать импорт backend-кода в клиентский bundle.
Монорепозиторий не обязателен. Пакет контрактов можно публиковать в закрытый npm registry и использовать в отдельных репозиториях.
Пакет контрактов
{
"name": "@app/contracts",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"zod": "^4.0.0"
}
}tsconfig.json общего пакета:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Конкретные настройки должны соответствовать сборщику и среде выполнения проекта.
import type и runtime-импорты
Тип существует только при компиляции:
import type { User } from "@app/contracts";Zod-схема нужна во время выполнения:
import { userSchema, type User } from "@app/contracts";Это принципиальное различие: User не способен проверить JSON, а userSchema способен.
Общий пакет должен быть безопасен для frontend и backend. Он не должен импортировать node:fs, ORM, базу данных или секретную конфигурацию.
Версионирование контрактов
Обычно совместимы:
- добавление нового endpoint;
- добавление необязательного поля запроса;
- добавление поля ответа, если старые клиенты игнорируют его.
Потенциально ломают клиентов:
- удаление или переименование поля;
- изменение типа поля;
- превращение необязательного поля в обязательное;
- изменение структуры ответа или допустимых литералов.
Даже в монорепозитории старая версия frontend может временно работать с новой версией backend. Поэтому изменения стоит делать обратно совместимыми или вводить новую версию API.
Контрактные тесты
TypeScript не гарантирует, что запущенный сервер реально вернёт заявленный JSON. Это проверяют контрактные тесты:
import { describe, expect, it } from "vitest";
import request from "supertest";
import { userSchema } from "@app/contracts";
import { app } from "../src/app.js";
describe("GET /api/users/:id", () => {
it("возвращает данные по контракту", async () => {
const response = await request(app)
.get("/api/users/550e8400-e29b-41d4-a716-446655440000")
.expect(200);
expect(userSchema.safeParse(response.body).success).toBe(true);
});
});Такие тесты особенно полезны, если клиент и сервер выпускаются независимо.
Альтернативы
Общий пакет Zod хорошо подходит TypeScript-монорепозиторию, но существуют другие варианты:
- OpenAPI — независимая спецификация, документация и генерация клиентов; удобна для публичных и многоязычных API.
- GraphQL — схема является контрактом, а типы генерируются из схемы и запросов.
- Code-first RPC — типы клиента выводятся из серверных процедур; обеспечивает тесную типобезопасную интеграцию, но сильнее связывает стороны.
Выбор зависит от количества клиентов, языков разработки, способа развёртывания и требований к документации.
Частые ошибки
Использование as вместо проверки
const user = json as User;Утверждение типа не валидирует данные. Используйте userSchema.parse(json).
Дублирование типа и схемы
Если тип и схема поддерживаются вручную, они могут разойтись. Выводите тип через z.infer.
Публикация ORM-моделей
Внутренняя модель может содержать пароль и служебные поля. Создавайте отдельные DTO.
Неверный тип даты
После JSON-передачи дата является строкой:
const schema = z.object({
createdAt: z.string().datetime(),
});z.date() подходит для уже созданного объекта Date, но не для обычной JSON-строки.
Только клиентская валидация
Проверка формы не защищает API. Backend всегда должен валидировать входные данные.
Отсутствие проверки ответа
Общие compile-time типы не защищают от устаревшего сервера, стороннего API или ошибки сериализации. Проверяйте важные ответы на границе клиента.
Слишком широкий контракт
Вместо role: z.string() лучше использовать z.enum([...]), если список значений ограничен.
Практические рекомендации
- Считайте сетевые данные и пользовательский ввод значениями
unknown. - Валидируйте данные на границе системы.
- Используйте Zod-схему как источник истины и выводите тип через
z.infer. - Отделяйте DTO от моделей базы данных.
- Проверяйте тело, параметры маршрута и query на backend.
- Проверяйте HTTP-статус и важные ответы на frontend.
- Используйте
safeParse()для ожидаемых ошибок ввода. - Не заменяйте валидацию утверждением
as. - Храните в общем пакете только контракты и безопасные чистые функции.
- Учитывайте обратную совместимость при развёртывании.
- Добавляйте контрактные тесты для критичных endpoint.
- Для публичного многоязычного API рассмотрите OpenAPI.
Краткая памятка
import { z } from "zod";
export const taskSchema = z.object({
id: z.string().uuid(),
title: z.string().trim().min(1),
completed: z.boolean(),
createdAt: z.string().datetime(),
});
export type Task = z.infer<typeof taskSchema>;
export const createTaskSchema = taskSchema.pick({
title: true,
});
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
const result = taskSchema.safeParse(unknownValue);
if (result.success) {
const task: Task = result.data;
console.log(task.title);
} else {
console.error(result.error.issues);
}Главная идея:
Общая Zod-схема → runtime-валидация → выведенный TypeScript-тип