Генерация деклараций типов совместно со сборкой

TypeScript-проекты, использующие Rollup, требуют отдельного этапа генерации деклараций типов, поскольку сам Rollup не выполняет типизацию и не умеет создавать .d.ts файлы из исходного кода. Это приводит к необходимости выстраивания параллельного процесса: сборка JavaScript-бандла и формирование типовых деклараций должны происходить синхронно и предсказуемо.

Файлы деклараций типов .d.ts являются контрактом между библиотекой и потребителем TypeScript. Они описывают структуру API без реализации, обеспечивая:

  • автодополнение в IDE
  • статическую проверку типов
  • документирование интерфейсов
  • совместимость с экосистемой TypeScript

В контексте Rollup-сборки основной JavaScript-бандл и типовые декларации существуют как два независимых артефакта. Это разделение требует координации на уровне сборочного пайплайна.

Базовый подход: tsc как генератор деклараций

Наиболее прямолинейный способ генерации .d.ts — использование компилятора TypeScript с включённой опцией emit declarations.

Ключевая настройка tsconfig.json:

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationMap": true,
    "outDir": "dist/types",
    "rootDir": "src"
  },
  "include": ["src"]
}

Здесь важно разделение обязанностей:

  • tsc отвечает только за типы
  • Rollup отвечает только за JavaScript

Такой подход исключает пересечения ответственности и предотвращает дублирование компиляции.

Разделение build-процессов

Типичная структура проекта с Rollup и TypeScript выглядит следующим образом:

src/
  index.ts
dist/
  index.js
  types/
    index.d.ts

Сборочный процесс разбивается на два независимых шага:

  1. Rollup собирает JS
  2. TypeScript генерирует .d.ts

Такой подход обеспечивает чистую архитектуру сборки, но требует синхронизации через npm scripts.

{
  "scripts": {
    "build:js": "rollup -c",
    "build:types": "tsc -p tsconfig.json",
    "build": "npm run build:js && npm run build:types"
  }
}

Основная проблема такого подхода заключается в отсутствии гарантии атомарности результата: JS может собраться, а типы — нет, и наоборот.

Использование rollup-plugin-dts

Для устранения рассинхронизации между JavaScript и типами используется специализированный плагин rollup-plugin-dts, который позволяет рассматривать .d.ts как входной граф и собирать их аналогично JS-коду.

Принцип работы отличается от tsc:

  • сначала генерируются отдельные .d.ts файлы
  • затем Rollup объединяет их в один файл деклараций

Конфигурация генерации типов

Первый этап остаётся за tsc, но с ограничением вывода:

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist/dts-temp",
    "rootDir": "src"
  }
}

Rollup-конфигурация для типов

import dts from "rollup-plugin-dts";

export default {
  input: "dist/dts-temp/index.d.ts",
  output: {
    file: "dist/index.d.ts",
    format: "es"
  },
  plugins: [dts()]
};

На выходе получается единый .d.ts файл, повторяющий структуру бандла.

Синхронизация JS и типов

Главная инженерная проблема — поддержание идентичной структуры JavaScript и типов. Rollup может:

  • переименовывать модули
  • инлайнить зависимости
  • изменять структуру экспорта
  • выполнять tree-shaking

TypeScript при этом генерирует декларации на основе исходной структуры, не учитывая трансформации Rollup.

Это приводит к необходимости соблюдать правило:

структура входных .ts модулей должна соответствовать публичному API, а не внутреннему устройству сборки

Именно поэтому часто вводится отдельный entry-point:

src/
  index.ts   // публичный API
  internal/  // не экспортируется

Проблема barrel-экспорта и типизации

При использовании barrel-файлов (index.ts с re-export) возникает расхождение между:

  • графом Rollup
  • графом TypeScript

Rollup может схлопывать re-export, тогда как TypeScript сохраняет оригинальные пути.

Это особенно критично при генерации .d.ts, так как итоговый файл может содержать:

  • дублированные типы
  • лишние импорты
  • неконсистентные пути

Решение — использовать rollup-plugin-dts как финальный шаг нормализации графа.

Инлайн деклараций и preserveModules

При включении output.preserveModules структура JS сохраняется 1:1 с исходниками. Это значительно упрощает согласование с типами.

export default {
  input: "src/index.ts",
  output: {
    dir: "dist",
    format: "esm",
    preserveModules: true
  }
};

Преимущество в том, что:

  • TypeScript и Rollup работают с одинаковой модульной структурой
  • .d.ts можно сопоставлять построчно с JS-модулями
  • упрощается отладка

Однако это увеличивает количество файлов в dist, что может быть нежелательно для библиотек с публичным API, ориентированным на единый бандл.

Монорепозитории и генерация типов

В монорепозиториях генерация типов усложняется из-за:

  • перекрёстных зависимостей пакетов
  • shared-типов
  • необходимости единых деклараций

Типичная стратегия:

  • каждый пакет генерирует свои .d.ts
  • затем выполняется агрегация на уровне workspace

Rollup в таких сценариях используется только на уровне отдельных пакетов, тогда как типы часто собираются отдельным инструментом (tsc или dts bundler).

Инкрементальная генерация типов

При больших кодовых базах полная пересборка .d.ts становится узким местом. Используются подходы:

  • разделение tsc --build (project references)
  • кеширование промежуточных .d.ts
  • раздельная генерация по пакетам

Project References позволяют TypeScript строить граф зависимостей и пересобирать только изменённые части:

tsconfig.json (root)
packages/
  core/
  ui/

Каждый пакет имеет собственный tsconfig.json с composite: true.

Конфликты с внешними трансформерами

При использовании дополнительных Rollup-плагинов (например, Babel или SWC) возникает риск рассинхронизации типов и фактического кода:

  • Babel может удалять type-only импорты
  • SWC может изменять экспортную структуру
  • Rollup может переупорядочивать экспорт

TypeScript при этом остаётся единственным источником истины для .d.ts, что требует строгого контроля соответствия входного кода и выходного бандла.

Паттерн dual build output

На практике формируется двойной выход:

  • dist/index.js — результат Rollup
  • dist/index.d.ts — результат tsc + dts bundler

Ключевое требование:

оба артефакта должны соответствовать одному и тому же публичному API-графу

Любое отклонение приводит к нарушению типовой совместимости и ошибкам в IDE у потребителей библиотеки.

Альтернативный подход: no-bundle declarations

Некоторые библиотеки отказываются от объединения .d.ts в один файл и оставляют структуру как есть:

dist/
  index.d.ts
  utils.d.ts
  helpers.d.ts

Это упрощает сборку и устраняет шаг bundling деклараций, но увеличивает сложность публикации API и требует корректного exports в package.json.

При использовании exports необходимо явно указать:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

Координация через package.json

Финальная интеграция Rollup и TypeScript происходит на уровне метаданных пакета. Важные поля:

  • types
  • exports
  • main
  • module

Корректная конфигурация гарантирует, что:

  • Node.js использует JS-бандл
  • TypeScript использует .d.ts
  • ESM и CJS могут сосуществовать без конфликтов

Разделение сборки и типизации становится не вспомогательной задачей, а частью архитектуры библиотеки, определяющей её совместимость и долгосрочную поддерживаемость.