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";

Общие контракты могут описывать:

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 zod
import { 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>; // number

z.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 zod
import { 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.yaml

pnpm-workspace.yaml:

packages:
  - "apps/*"
  - "packages/*"

Приложения подключают локальный пакет:

{
  "dependencies": {
    "@app/contracts": "workspace:*"
  }
}

Преимущества монорепозитория:

Сложности:

Монорепозиторий не обязателен. Пакет контрактов можно публиковать в закрытый 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, базу данных или секретную конфигурацию.


Версионирование контрактов

Обычно совместимы:

Потенциально ломают клиентов:

Даже в монорепозитории старая версия 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-монорепозиторию, но существуют другие варианты:

Выбор зависит от количества клиентов, языков разработки, способа развёртывания и требований к документации.


Частые ошибки

Использование 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([...]), если список значений ограничен.


Практические рекомендации

  1. Считайте сетевые данные и пользовательский ввод значениями unknown.
  2. Валидируйте данные на границе системы.
  3. Используйте Zod-схему как источник истины и выводите тип через z.infer.
  4. Отделяйте DTO от моделей базы данных.
  5. Проверяйте тело, параметры маршрута и query на backend.
  6. Проверяйте HTTP-статус и важные ответы на frontend.
  7. Используйте safeParse() для ожидаемых ошибок ввода.
  8. Не заменяйте валидацию утверждением as.
  9. Храните в общем пакете только контракты и безопасные чистые функции.
  10. Учитывайте обратную совместимость при развёртывании.
  11. Добавляйте контрактные тесты для критичных endpoint.
  12. Для публичного многоязычного 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-тип