Модули и файлы деклараций: 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.
расширения в путях обязательны"] CJS --> E["require работает, import — только динамический,
расширения можно опускать"]
Отсюда правило гигиены: всегда указывайте "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 идут
по ней сверху вниз и берут первое подходящее условие.
типы и код это РАЗНЫЕ файлы
Правила, которые ловят 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 не переписывает пути в
эмите. Варианты решения, по возрастанию «стандартности»:
- Бандлер с тем же алиасом (Vite/esbuild/webpack) — путь исчезает при сборке.
- Пост-обработка: tsc-alias.
- 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>;
}
Типы для стороннего пакета берутся из трёх источников — компилятор проверяет их именно в этом порядке:
- Встроенные типы пакета —
.d.tsрядом с кодом, указанные черезexportsили полеtypes. Идеальный случай: типы и реализация версионируются вместе. @types/имя-пакета— отдельный пакет из DefinitelyTyped, огромного репозитория типов, поддерживаемого сообществом. Так живутexpress,nodeи тысячи старых библиотек. Риск: типы могут отставать от реализации.- Ваши локальные объявления — если типов нет нигде, вы пишете их сами.
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 Handbook: Modules и Module Resolution Reference.
- Node.js: Modules — Packages —
exports,imports, условия, dual package hazard. - Declaration Files: Deep Dive
— как писать
.d.ts. - DefinitelyTyped — где живут
@types/*. - publint, Are the types wrong? — проверка публикуемого пакета.
Что дальше
Мы разобрались, как код и типы попадают из одного пакета в другой. Теперь — про крупную конструкцию внутри самого кода: классы, ООП-механику TypeScript и декораторы, на которых стоят NestJS, TypeORM и половина энтерпрайз-экосистемы.