Коротко: Разбираем организацию общих типов (DTO) в Fullstack React и Next.js: монорепозитории, сериализация в RSC, Server Actions и валидация через Zod.
Разделение приложения на фронтенд и бэкенд в рамках одного стека на TypeScript создает иллюзию типобезопасности. Если интерфейсы ответов API или пропсы компонентов дублируются вручную, любая правка схемы в базе данных или бизнес-логике неизбежно приводит к скрытым ошибкам в рантайме.
Появление React Server Components (RSC) и Server Actions в экосистеме Next.js размыло классическую границу между клиентом и сервером. Данные теперь передаются не только через HTTP REST-эндпоинты, но и напрямую через RSC-протокол. В таких условиях сквозная типизация (End-to-End Type Safety) становится базовым инженерным требованием, позволяющим снизить технический долг и исключить рассинхронизацию контрактов.
Почему сквозная типизация критична в современном Fullstack React
Проблема рассинхронизации API-контрактов и клиентского состояния
Классический сценарий деградации кодовой базы: бэкенд-разработчик переименовывает поле userId в authorId или меняет тип status: 'active' | 'inactive' на расширенный union с 'pending'. Если клиентское приложение описывает свои типы изолированно, компилятор tsc успешно соберет билд фронтенда. Ошибка проявится только у конечного пользователя в виде undefined в интерфейсе или сломанного рендеринга.
Сквозная типизация превращает контракт данных в единый источник правды (Single Source of Truth). Изменение структуры на сервере мгновенно подсвечивает ошибки сборки во всех зависимых клиентах, формах и UI-компонентах.
DTO против доменных сущностей: где провести границу шаринга типов
Распространенная ошибка проектирования — прямой экспорт моделей ORM (Prisma, Drizzle, TypeORM) на клиент. Это нарушает инкапсуляцию и создает риски безопасности:
- Утечка приватных данных: клиентский бандл не должен содержать сведений о полях вроде
passwordHash,internalAuditMetadataилиstripeCustomerId. - Лишние зависимости: сущности базы данных часто содержат специфичные серверные типы и методы, которые невозможно или не нужно бандлить в браузер.
- Разница структур: то, что хранится в базе данных в нормализованном виде, для UI часто требуется в агрегированном или преобразованном формате.
Для шаринга типов используются исключительно DTO (Data Transfer Objects) — плоские контракты, описывающие форму передаваемых данных по сети или через границу сериализации RSC.
[База данных / ORM Model]
│ (маппинг и сериализация на сервере)
▼
[Shared DTO / Схемы] ◄─── Единый контракт (TypeScript)
│
├──► [Server Actions / API Route]
└──► [React Server Components ──► Client Components]
Архитектурные паттерны организации Shared Types
Выбор способа организации общих типов зависит от топологии репозитория и масштаба команды.
Подход 1: Монорепозиторий (Turborepo / Nx / pnpm workspaces)
Наиболее масштабируемый вариант для Fullstack-проектов. В монорепозитории создается отдельный легковесный пакет, например packages/types или packages/contracts:
my-monorepo/
├── apps/
│ ├── web/ # Next.js (RSC, Client)
│ └── api/ # NestJS / Express / Fastify
└── packages/
├── types/ # Чистые DTO и интерфейсы
│ ├── package.json
│ ├── index.ts
│ └── tsconfig.json
└── validation/ # Схемы Zod / Valibot
В package.json веб-приложения и API пакет подключается через workspace:*. При сборке TypeScript разрешает ссылки на исходные .ts файлы без необходимости промежуточной публикации.
Подход 2: Единый монолит Next.js (@/types или @/lib/types)
Если все fullstack-приложение развернуто внутри Next.js (App Router), создавать монорепозиторий необязательно. Достаточно выделить директорию верхнего уровня:
src/
├── app/
│ ├── api/users/route.ts # Серверный эндпоинт
│ └── users/page.tsx # RSC
├── components/ # Client Components
└── shared/
└── types/ # Экспортируемые DTO
├── user.dto.ts
└── api.dto.ts
Через алиасы путей в tsconfig.json (@/shared/*) типы импортируются как в серверные модули, так и в компоненты с директивой "use client".
Подход 3: Отдельный npm-пакет для мультирепозиториев
Когда фронтенд и бэкенд находятся в разных репозиториях и разрабатываются изолированными командами, контракты выносятся в отдельный приватный npm-пакет (GitHub Packages или частный реестр).
Минус подхода — необходимость соблюдения SemVer, публикации и ручного обновления версий зависимостей в проектах при каждом изменении схемы.
Особенности типизации в React Server Components (RSC)
Архитектура RSC вводит строгие правила передачи данных, которых не было в классическом SPA на React.
Граница Server / Client ("use client") и правила сериализуемости
Компоненты в Next.js App Router по умолчанию являются серверными (RSC). Когда RSC передает пропсы в интерактивный клиентский компонент ("use client"), данные проходят через границу сериализации по протоколу React Server Components.
Что можно передавать через пропсы RSC -> Client:
- Примитивы (
string,number,boolean,null,undefined); - Простые объекты и массивы;
Date(RSC-протокол поддерживает встроенную передачу дат);BigInt;Promise(для использования с хукомuse());- JSX-элементы (в качестве
childrenили слотов).
Что передавать запрещено:
- Функции (за исключением Server Actions);
- Экземпляры пользовательских классов с методами;
- Символы (
Symbol); - Мутабельные ссылки и дескрипторы сокетов/соединений к БД.
// types/user.dto.ts
export interface UserProfileDTO {
id: string;
name: string;
createdAt: Date; // Валидно для RSC -> Client пропсов в Next.js
}
Типизация Server Actions и мутаций данных
Server Actions работают как типизированные RPC-вызовы. Сигнатура функции должна явно возвращать предсказуемый контракт ответа или статус ошибки.
// shared/types/actions.dto.ts
export type ActionResponse<T> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
// app/actions/update-profile.ts
'use server';
import { ActionResponse, UserProfileDTO } from '@/shared/types';
export async function updateProfileAction(
userId: string,
formData: FormData
): Promise<ActionResponse<UserProfileDTO>> {
// Серверная логика
return {
success: true,
data: { id: userId, name: 'Alice', createdAt: new Date() }
};
}
Получение данных в RSC: прямые вызовы vs REST
Внутри RSC нет необходимости обращаться к собственным API Routes через fetch(). Серверный компонент запрашивает данные напрямую из Data Access Layer (DAL).
При этом тип данных, возвращаемый DAL, должен быть строго приведен к DTO перед передачей в клиентские интерактивные деревья, чтобы исключить утечку серверных методов и приватных свойств.
Пошаговая реализация: от схемы валидации до компонентов
Для достижения полной безопасности типов связываются статический TypeScript и рантайм-валидаторы (например, Zod). Типы выводятся непосредственно из схем валидации (type inference).
[Zod Schema] ──(z.infer)──► [TypeScript Type (DTO)]
│ │
▼ ▼
[Рантайм-валидация] [Статический контроль в IDE]
(Safe parse на сервере) (Автодополнение в RSC / Client)
Шаг 1. Определение схемы и генерация DTO
// shared/contracts/post.contract.ts
import { z } from 'zod';
export const PostDTOSchema = z.object({
id: z.string().uuid(),
title: z.string().min(3).max(120),
content: z.string(),
viewsCount: z.number().int().nonnegative(),
publishedAt: z.string().datetime(),
});
// Статический тип, выведенный из схемы
export type PostDTO = z.infer<typeof PostDTOSchema>;
export const CreatePostSchema = PostDTOSchema.omit({
id: true,
viewsCount: true,
publishedAt: true
});
export type CreatePostInput = z.infer<typeof CreatePostSchema>;
Шаг 2. Использование типов в серверном слое (RSC / Action)
// app/posts/page.tsx (Server Component)
import { PostDTO } from '@/shared/contracts/post.contract';
import { PostCard } from '@/components/PostCard';
async function getPosts(): Promise<PostDTO[]> {
// Запрос к базе данных через DAL/ORM
const rawPosts = await db.post.findMany({ where: { published: true } });
// Маппинг и подготовка DTO
return rawPosts.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
viewsCount: post.views,
publishedAt: post.createdAt.toISOString(),
}));
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<main>
<h1>Список публикаций</h1>
<div className="grid gap-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}
Шаг 3. Использование строго типизированных пропсов в Client Component
// components/PostCard.tsx
'use client';
import { PostDTO } from '@/shared/contracts/post.contract';
import { useState } from 'react';
interface PostCardProps {
post: PostDTO;
}
export function PostCard({ post }: PostCardProps) {
const [likes, setLikes] = useState(0);
return (
<article className="border p-4 rounded-md">
<h2>{post.title}</h2>
<p>{post.content}</p>
<div className="text-sm text-gray-500">
Опубликовано: {new Date(post.publishedAt).toLocaleDateString()}
</div>
<button onClick={() => setLikes((prev) => prev + 1)}>
Лайк ({likes})
</button>
</article>
);
}
Частые ошибки и антипаттерны
Импорт серверного кода в shared-модули. Если файл
types.tsимпортирует типы или утилиты из модулей, где задействованыfs,crypto, серверные заголовки или клиенты БД, сборщик попытается включить их в клиентский бандл. Общие модули должны содержать исключительно чистые типы и легковесные валидаторы.Использование
anyилиunknownв ответах API. Конструкции видаconst data = await res.json() as anyполностью ломают сквозную безопасность. Если схема ответа не гарантирована на 100%, данные необходимо валидировать через.safeParse()перед использованием.Смешение REST-сериализации и RSC-сериализации. Объект
Date, передаваемый через стандартный HTTP API Route (Response.json()), превращается вstring(ISO-формат). Тот же объектDate, переданный из RSC в Client Component через пропсы, сохраняет типDate. Если один и тот же DTO используется и в REST, и в RSC, безопаснее стандартизировать формат дат (например, строгоstringв формате ISO 8601).
Чек-лист для внедрения Shared Types
| Область проверки | Критерий готовности |
|---|---|
| Изоляция ORM | Модели базы данных не импортируются в клиентские компоненты напрямую |
| DTO-контракты | Для каждого сетевого ответа и Server Action описан явный интерфейс |
| Синхронизация схем | Типы TypeScript выводятся из Zod/Valibot-схем через z.infer |
| Чистота бандла | Пакет общих типов не содержит Node.js API и секретов окружения |
| Граница сериализации | Пропсы Client Components содержат только сериализуемые типы |
| Конфигурация TS | В tsconfig.json включен режим "strict": true |
Часто задаваемые вопросы (FAQ)
Можно ли передавать объекты Date через пропсы из Server Component в Client Component?
Да. Внутренний протокол сериализации React Server Components в Next.js умеет передавать и восстанавливать нативные объекты Date. Однако если те же данные отдаются через стандартный Route Handler (app/api/...), при вызове Response.json() даты превратятся в строки. Чтобы избежать расхождений в общих типах, надежнее использовать строковый формат ISO 8601.
Чем DTO отличается от ORM-модели при организации типов?
ORM-модель описывает структуру таблицы базы данных, включая внутренние идентификаторы, служебные даты, внешние ключи и хеши. DTO (Data Transfer Object) — это срез данных, предназначенный исключительно для передачи по сети или между изолированными слоями интерфейса. DTO защищает от случайной утечки серверных данных.
Что выбрать: монорепозиторий или отдельный npm-пакет?
Если приложения (фронтенд и бэкенд) разрабатываются в рамках одного продуктового цикла одной командой, монорепозиторий на базе pnpm workspaces или Turborepo значительно удобнее: типы обновляются мгновенно без пересборок и публикаций. Отдельный npm-пакет оправдан при жестком разделении инфраструктуры и команд.
Нужен ли tRPC, если в проекте уже есть Next.js App Router и Server Actions?
Server Actions закрывают большинство потребностей в типизированных RPC-мутациях и вызовах данных без сторонних библиотек. tRPC остается полезным инструментом, если у вас SPA (Vite + React) без серверных компонентов или требуется обслуживать внешние мобильные клиенты с единого сервера Node.js.
Попадают ли Shared Types в итоговый бандл браузера?
Чистые типы и интерфейсы TypeScript (type, interface) полностью удаляются на этапе транспиляции и имеют нулевой размер в бандле. Если общий модуль содержит исполняемый JS-код (например, схемы валидации или enum), он попадет в клиентский бандл. Поэтому схемы валидации больших бэкенд-сущностей стоит разделять на клиентские и серверные части.




.svg.webp)





