TypeScript Модули и файлы деклараций: ESM, CommonJS и типы чужого кода
0%

Модули и файлы деклараций: ESM, CommonJS и типы чужого кода

Модули и файлы деклараций: ESM, CommonJS и типы чужого кода

Есть класс ошибок, на которых спотыкается каждый TypeScript-разработчик, и они не про систему типов вовсе:

Cannot find module 'lodash' or its corresponding type declarations.
ERR_REQUIRE_ESM: require() of ES Module ... not supported
ERR_MODULE_NOT_FOUND: Cannot find module '/app/dist/config' imported from ...
The current file is a CommonJS module whose imports will produce 'require' calls

Все четыре — про модульную систему и файлы деклараций: два слоя, которые живут между вашим кодом и чужим. В курсе мы их касались вскользь (в тулчейнеmodule/moduleResolution, в деплое — публикация пакета). Здесь разберём до дна: это та часть TypeScript, где «магии» больше всего, а документации в голове у людей — меньше всего.

Почему всё так сложно: две модульные системы

JavaScript тридцать лет жил без модулей. Языковой стандарт появился только в 2015 году, а до него экосистема придумала свои форматы — и теперь мы живём в мире, где сосуществуют оба.

Разница между CommonJS (CJS) и ES Modules (ESM) — не косметическая. Это разные модели загрузки, и отсюда все несовместимости:

CommonJS ES Modules
Синтаксис require() / module.exports import / export
Когда резолвится в рантайме, при вызове require статически, до исполнения
Загрузка синхронная (блокирует поток) асинхронная, с фазой связывания
Что получает импортёр копию значения на момент require live binding — ссылку на переменную
Условные импорты if (x) require("a") — можно только await import("a")
Top-level await нельзя можно
Циклы частично работают, отдают неполный объект обрабатываются фазой связывания
Tree-shaking почти невозможен возможен (статичность)

Live bindings — тонкость, которая иногда объясняет странности: в ESM импорт это не копия, а ссылка. Если модуль позже изменит экспортируемую переменную, импортёр увидит новое значение. В CJS он увидел бы старое.

Практический вывод для 2026 года: новые проекты пишите на ESM. CJS остаётся в легаси и в инструментах, которым нужен синхронный require.

Как Node решает, чем считать файл

Это первый источник загадок: один и тот же .js-файл Node может трактовать и как CJS, и как ESM, в зависимости от ближайшего package.json.

Отсюда правило гигиены: всегда указывайте "type" явно в package.json, даже если это "commonjs". Неявное поведение по умолчанию — источник сюрпризов при переносе кода между пакетами монорепо.

Node 22.12+ умеет require() синхронного ESM-модуля без флага (документация), что снимает часть боли, но не отменяет необходимость понимать модель.

Как TypeScript резолвит импорты

tsc должен воспроизвести логику рантайма — иначе он проверит одно, а исполнится другое. За это отвечает опция moduleResolution, и это самая недооценённая настройка в tsconfig.json.

Значение Что имитирует Когда выбирать
node10 (бывш. node) старый алгоритм Node для CJS только легаси; не знает про exports
node16 / nodenext современный Node: type, exports, imports код, который исполняет Node напрямую
bundler как резолвят Vite/esbuild/webpack код, который проходит через бандлер
classic доисторический никогда

Ключевое различие в практике: при nodenext относительные импорты обязаны содержать расширение, причём расширение результата компиляции:

// nodenext: пишем .js, хотя рядом лежит config.ts — импортируется то, что выедет в dist
import { config } from "./config.js";

// bundler: расширение не нужно, бандлер разберётся сам
import { config } from "./config";

Это самая частая причина ERR_MODULE_NOT_FOUND в проде: локально всё работало через tsx, а собранный код упал, потому что расширения не проставлены.

TypeScript 5.7 добавил rewriteRelativeImportExtensions — можно писать ./config.ts и получать ./config.js в эмите. Опция полезна для проектов, которые исполняют исходники напрямую (Node с --experimental-strip-types, Deno, Bun) и при этом иногда собираются.

Условные exports: как пакет отдаёт разные файлы разным потребителям

Современный пакет описывает свои входные точки картой exports. Node и tsc идут по ней сверху вниз и берут первое подходящее условие.

Соответствие исходников, артефактов и exports-мапы пакета

Правила, которые ловят 90% проблем публикации:

  • Условие "types" ставится первым в каждом блоке. Резолвер берёт первое совпадение, и если "import" окажется выше, типы просто не найдутся.
  • exports запечатывает пакет: всё, что не перечислено, недоступно для импорта. Это фича — публичный API становится явным.
  • Проверяйте конфигурацию инструментами publint и Are the types wrong? — они ловят несогласованность exports, types и формата модулей до того, как это сделают ваши пользователи.

Алиасы путей: paths не переписывает импорты

Классическая ловушка. Вы прописали в tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@app/*": ["src/*"] }
  }
}

Компилятор перестал ругаться, редактор ходит по ссылкам — но в собранном JS осталось буквальное require("@app/domain/user"), и Node такого модуля не знает. Причина: paths влияет только на проверку типов, tsc не переписывает пути в эмите. Варианты решения, по возрастанию «стандартности»:

  1. Бандлер с тем же алиасом (Vite/esbuild/webpack) — путь исчезает при сборке.
  2. Пост-обработка: tsc-alias.
  3. Subpath imports — стандарт Node, работает без инструментов:
// package.json — алиасы, которые понимает сам рантайм
{
  "imports": {
    "#domain/*": "./dist/domain/*.js"
  }
}
// в коде — и tsc, и Node резолвят это одинаково
import { User } from "#domain/user.js";

Третий вариант предпочтителен для сервисов, которые Node исполняет напрямую: меньше инструментов между вами и рантаймом — меньше расхождений.

import type и стирание импортов

Импорт, который используется только в типах, должен исчезнуть при компиляции. Но компилятор не всегда может это доказать — особенно при пофайловой транспиляции (esbuild/swc видят один файл и не знают, тип там или значение). Отсюда import type и три опции.

// Явно: это только тип, импорт будет стёрт целиком
import type { UserRepository } from "./ports.js";

// Смешанный импорт: значение + тип, тип помечен инлайн
import { createPool, type PoolConfig } from "./db.js";

// Импорт ради побочного эффекта — сохраняется всегда
import "./telemetry.js";
  • verbatimModuleSyntax: true (TS 5.0+) — эмит один в один: всё, что не помечено type, остаётся в выводе. Никакой эвристики, никаких сюрпризов с «пропавшим побочным эффектом». Включайте в новых проектах.
  • isolatedModules: true — запрещает конструкции, которые нельзя корректно скомпилировать по одному файлу (реэкспорт типа без export type, const enum из другого модуля). Обязательна, если сборка идёт esbuild/swc/Babel.
  • skipLibCheck: true — не проверять типы внутри node_modules. Строго говоря, это ослабление, но без него сборка тонет в конфликтах чужих .d.ts; индустриальная норма — включать.

Dual package hazard

Пакет, опубликованный сразу в ESM и CJS, может быть загружен в один процесс дважды — по одной копии на формат. Два экземпляра модуля означают два разных класса Error, два разных «синглтона», два реестра.

// Пакет @acme/token загружен и как ESM (вашим кодом), и как CJS (чужой библиотекой)
import { AuthError } from "@acme/token";        // экземпляр A

try {
  await libraryThatUsesRequire.verify(token);   // внутри бросает AuthError из экземпляра B
} catch (err) {
  // false! Классы структурно одинаковые, но это РАЗНЫЕ функции-конструкторы
  console.log(err instanceof AuthError);
}

Лечится одним из трёх способов: публиковать только ESM (тренд экосистемы); держать состояние в тонком CJS-ядре, поверх которого обе обёртки; не полагаться на instanceof для кросс-пакетных проверок — использовать дискриминатор (err.code === "AUTH"), как мы делали в главе про ошибки.

Файлы деклараций: где живут типы

.d.ts — файл, содержащий только объявления, без реализации. Это интерфейс пакета для компилятора: рантайму он не нужен, а tsc и редактор читают только его.

// dist/index.d.ts — ни одной строки исполняемого кода
export interface ClientOptions {
  baseUrl: string;
  timeoutMs?: number;
}
export declare function createClient(opts: ClientOptions): Client;
export declare class Client {
  get<T>(path: string): Promise<T>;
}

Типы для стороннего пакета берутся из трёх источников — компилятор проверяет их именно в этом порядке:

  1. Встроенные типы пакета.d.ts рядом с кодом, указанные через exports или поле types. Идеальный случай: типы и реализация версионируются вместе.
  2. @types/имя-пакета — отдельный пакет из DefinitelyTyped, огромного репозитория типов, поддерживаемого сообществом. Так живут express, node и тысячи старых библиотек. Риск: типы могут отставать от реализации.
  3. Ваши локальные объявления — если типов нет нигде, вы пишете их сами.
pnpm add -D @types/node @types/express   # типы для рантайма и для библиотеки

Как типизировать библиотеку без типов

Рецепт зависит от того, сколько вам нужно от этой библиотеки.

// src/types/legacy-chart.d.ts

// Вариант 1: заглушка. Модуль становится any — быстро, но без пользы.
declare module "legacy-chart";

// Вариант 2: типизируем ровно то, что используем. Обычно этого достаточно.
declare module "legacy-chart" {
  export interface ChartOptions {
    width: number;
    height: number;
    animate?: boolean;
  }
  export function render(el: HTMLElement, data: number[], opts?: ChartOptions): void;
  export const version: string;
}

Чтобы компилятор увидел этот файл, он должен попадать в include (например, "include": ["src"] и файл лежит в src/types/). Проверьте, что расширение именно .d.ts — обычный .ts с declare module тоже сработает, но соглашение важнее.

Правило безопасности из главы про типы действует и здесь: рукописный .d.ts — это обещание компилятору, которое никто не проверяет. Если библиотека вернёт не то, вы получите падение в рантайме. На критичных путях валидируйте результат схемой.

Ambient-объявления: типы для того, что не является модулем

declare global описывает то, что доступно везде без импорта. Работает только внутри файла-модуля (в файле должен быть хотя бы один import/export):

// src/types/global.d.ts
export {}; // делает файл модулем — без этого declare global не разрешён

declare global {
  // расширяем глобальный объект: своё поле в Window
  interface Window {
    __APP_VERSION__: string;
  }

  // типизируем переменные окружения (осторожно: это обещание, а не проверка)
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
    }
  }
}

Отдельный частый случай — импорт не-JS ресурсов, которые умеет подставлять бандлер:

// src/types/assets.d.ts — иначе import logo from "./logo.svg" не скомпилируется
declare module "*.svg" {
  const src: string;
  export default src;
}
declare module "*.css";

Module augmentation: расширяем чужие типы

Самый практичный приём: добавить поле в интерфейс, объявленный библиотекой. Так типизируют request.user после аутентификации:

// src/types/fastify.d.ts
import "fastify"; // импорт обязателен: аугментируем СУЩЕСТВУЮЩИЙ модуль

declare module "fastify" {
  interface FastifyRequest {
    // теперь req.user типизирован во всём проекте
    user?: { id: string; roles: readonly string[] };
  }
}

Под капотом работает declaration merging: два объявления interface с одинаковым именем в одной области сливаются в одно. Это же свойство объясняет, почему interface расширяем, а type — нет (см. главу про фундамент).

// Слияние интерфейсов: два объявления -> один тип с обоими полями
interface Config { host: string }
interface Config { port: number }
const c: Config = { host: "localhost", port: 5432 }; // требуются оба

// Слияние функции и пространства имён: функция со "статикой"
function formatDate(d: Date): string { return d.toISOString(); }
namespace formatDate {
  export const iso = "YYYY-MM-DD";
}
formatDate.iso; // "YYYY-MM-DD" — типизировано

Аугментация — мощный, но опасный инструмент: она глобальна для всего проекта. Держите такие файлы в одном каталоге src/types/ и ревьюйте особенно строго.

typeRoots, types и скорость

По умолчанию компилятор подтягивает все пакеты из node_modules/@types — в большом монорепо это десятки тысяч строк объявлений на каждую сборку. Если знаете точно, что нужно, ограничьте список:

{
  "compilerOptions": {
    // включаем ТОЛЬКО эти глобальные типы; остальные @types игнорируются
    "types": ["node", "vitest/globals"]
  }
}

Это заметно ускоряет и tsc, и языковой сервер в редакторе. Подробнее о том, где ещё теряется время компилятора, — в главе про миграцию и производительность.

isolatedDeclarations — быстрая генерация .d.ts

TypeScript 5.5 добавил флаг isolatedDeclarations. Он требует, чтобы у всех экспортируемых сущностей были явные аннотации типов:

// Ошибка при isolatedDeclarations: тип возврата надо вывести, а значит — нужен чекер
export function makeId() {
  return crypto.randomUUID();
}

// Корректно: .d.ts можно построить, глядя только на этот файл
export function makeId(): string {
  return crypto.randomUUID();
}

Смысл в том, что декларации становятся генерируемыми пофайлово и параллельно, без полного анализа программы, — это открывает дорогу быстрым сборщикам (oxc, swc) к генерации .d.ts. Цена — многословность. Разумный выбор для публикуемых библиотек в монорепо, избыточный для приложения.

Практические грабли модульной организации

  • Барельные файлы (index.ts с реэкспортами). Удобны для импорта, вредны для всего остального: провоцируют циклы, ломают инкрементальность (правка одного файла инвалидирует весь барель), тянут в бандл лишнее. В крупных кодовых базах от них отказываются в пользу глубоких импортов.
  • Циклические зависимости. На уровне типов TypeScript их переваривает. На уровне значений в рантайме вы получите undefined в момент инициализации. Ищите циклы через madge или eslint-plugin-import; лечите вынесением общего в третий модуль.
  • export default в библиотеках. Плохо переживает интероп CJS/ESM и переименования при рефакторинге. Именованные экспорты предсказуемее.
  • esModuleInterop: true нужен, чтобы import express from "express" работал с CJS-пакетом. Без него потребуется import * as express, а это не то же самое. Включайте — это де-факто стандарт.
  • allowJs без checkJs — JS-файлы попадут в сборку, но не будут проверены; осознанно решайте, чего хотите (см. следующую главу про миграцию).

Типичные ошибки

  • Забыть расширение .js в относительных импортах при nodenext — код собирается, но падает в рантайме.
  • Поставить "types" не первым в exports — потребители не видят типов.
  • Считать, что paths работает в рантайме.
  • Написать declare module "x" без тела и решить, что библиотека «типизирована»: на самом деле вы выключили проверку для неё целиком.
  • Держать @types/* в dependencies вместо devDependencies — они не нужны в проде и раздувают образ.
  • Публиковать пакет без проверки publint — и узнавать о поломке от пользователей.

Источники

Что дальше

Мы разобрались, как код и типы попадают из одного пакета в другой. Теперь — про крупную конструкцию внутри самого кода: классы, ООП-механику TypeScript и декораторы, на которых стоят NestJS, TypeORM и половина энтерпрайз-экосистемы.

Классы, ООП и декораторы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов