Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Shared Types в Fullstack React: как организовать сквозную типизацию между API, RSC и Client Components

Тимофей Ищенко

Коротко: Разбираем организацию общих типов (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) на клиент. Это нарушает инкапсуляцию и создает риски безопасности:

  1. Утечка приватных данных: клиентский бандл не должен содержать сведений о полях вроде passwordHash, internalAuditMetadata или stripeCustomerId.
  2. Лишние зависимости: сущности базы данных часто содержат специфичные серверные типы и методы, которые невозможно или не нужно бандлить в браузер.
  3. Разница структур: то, что хранится в базе данных в нормализованном виде, для 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>
  );
}

Частые ошибки и антипаттерны

  1. Импорт серверного кода в shared-модули. Если файл types.ts импортирует типы или утилиты из модулей, где задействованы fs, crypto, серверные заголовки или клиенты БД, сборщик попытается включить их в клиентский бандл. Общие модули должны содержать исключительно чистые типы и легковесные валидаторы.

  2. Использование any или unknown в ответах API. Конструкции вида const data = await res.json() as any полностью ломают сквозную безопасность. Если схема ответа не гарантирована на 100%, данные необходимо валидировать через .safeParse() перед использованием.

  3. Смешение 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), он попадет в клиентский бандл. Поэтому схемы валидации больших бэкенд-сущностей стоит разделять на клиентские и серверные части.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также