Модули и резолюция
Модуль в TypeScript — это не косметика синтаксиса, а граница между тем, что вы пишете, и тем, что исполняет рантайм. Правило ровно одно: файл с top-level import или export — это модуль со своей областью видимости; файл без единого из них — скрипт, чьи объявления попадают в глобальную область. Из этого одного факта растут почти все «duplicate identifier» и «cannot find name», а идиома export {} существует затем, чтобы принудительно сделать файл модулем. Поверх этого лежат две ортогональные оси, которые постоянно путают: опция module решает, какой синтаксис эмитит tsc (commonjs переписывает import/export в require/exports, esnext оставляет их ES-модулями), а moduleResolution решает, как строка-спецификатор вроде 'lodash' превращается в файл на диске. Ни одна из них не меняет то, что вы написали в исходнике.
Отсюда и стенд ловушек, который стоит назвать сразу. Типы в TypeScript стираются — поэтому import type гарантированно не эмитит ничего, а paths-алиас никогда не доходит до рантайма и требует отдельного резолвера. CommonJS не знает понятия «экспорт по умолчанию» — поэтому esModuleInterop синтезирует его хелпером, а без него import fs from 'fs' собирается, но падает. namespace — доэсэмное наследие, живое лишь в .d.ts. И версии продолжают резать: TS 6.0 объявил устаревшими moduleResolution: node10, baseUrl, outFile и esModuleInterop: false, а TS 7.0 (GA 8 июля 2026, компилятор на Go) сделал их жёсткими ошибками. Разбор по слоям — ниже.
Карта темы
- Файл-модуль против файла-скрипта — правило «есть top-level
import/export→ модуль», статическая анализируемость ESM, tree-shaking и почему циклический импорт падает в CommonJS. - Interop с CommonJS — чем
module.exports = xотличается отexport default x, что делаетesModuleInteropи почемуimport * as expressперестаёт быть вызываемым. - Резолюция модулей —
node10противnode16/nodenextпротивbundler,pathsтолько на этапе компиляции и почему алиас падает в рантайме. - import type и стирание — почему
tscвырезает импорт типа, а однофайловый транспилятор — нет, что даётisolatedModulesиverbatimModuleSyntax. - namespace — наследие — IIFE на глобальном объекте, почему ES-модули его вытеснили и где
declare namespaceвсё ещё уместен. - Дополнение модуля — как добавить свойство в типы сторонней библиотеки через
declare module, требование к файлу-модулю и пределы слияния. - Динамический import() —
import()как выражение-Promise и как тип, вычисляемый спецификатор даётany, аmodule: commonjsломает code-splitting. - Типы для JS-пакетов — откуда берутся
@types/*, порядок условияtypesвexportsи что на самом деле выключаетskipLibCheck.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать, что module меняет синтаксис, который вы пишете | module задаёт лишь то, что эмитит tsc; писать вы продолжаете import/export при любом значении |
Путать module (формат вывода) с target (версия JavaScript) | Это разные оси: target понижает синтаксис языка, module выбирает формат модулей |
Путать module (формат) с moduleResolution (поиск файла) | Одна опция решает, как эмитить, другая — как найти файл на диске; они независимы |
Оставить файл без единого top-level import/export | Это скрипт: объявления утекают в глобал, отсюда «duplicate identifier» — лечится export {} |
Включить один allowSyntheticDefaultImports и ждать работы в рантайме | Он типовой, эмит не меняет; синтетический default в рантайме создаёт только esModuleInterop |
Писать import * as express from 'express'; express() | Под esModuleInterop namespace-объект не вызываем — нужен default-импорт |
Оставить moduleResolution: node10 на пакете с картой exports | exports игнорируется, отдаётся не тот файл; в TS 7.0 node10 — жёсткая ошибка |
Настроить paths-алиас только в tsconfig.json | tsc не переписывает спецификатор — Node видит буквальный @app/x и бросает MODULE_NOT_FOUND |
Считать namespace стираемым, как interface | Он эмитит настоящий IIFE и навешивает свойства на глобальный объект |
Писать declare module 'lib' в файле-скрипте | Без top-level import/export блок заменяет типы библиотеки, а не сливается с ними |
| Пытаться дополнением поменять тип существующего члена | Слияние объявлений умеет только добавлять члены, а не переопределять |
Ждать, что вычисляемый import(\./${name}\) типизирован | Файла, на который смотреть, нет — результат any, форму надо проверять в рантайме |
Оставить module: commonjs при await import() | import() понижается в require() — тихо ломается code-splitting и ESM-only пакеты |
Держать обычный import ради одного типа | Модуль втягивается в бандл и может создать рантайм-цикл; пишите import type |
Ставить @types/x для пакета со своими объявлениями | Лишний, а устаревший @types затеняет настоящие типы библиотеки |
Ждать, что tsc сообщит о циклическом импорте | Типы резолвятся лениво, проверка молчит; падает уже рантайм на undefined в extends |
Значение для собеседований
Тема модулей — это проверка модели исполнения, а не словаря. Интервьюер хочет услышать, что вы держите в голове два разных вопроса: что tsc эмитит (и что при этом остаётся неизменным в исходнике) и что физически происходит, когда Node или bundler грузит получившийся код. Джуна от middle отделяет один вопрос — «чем module отличается от moduleResolution?»: правильный ответ не «одно новее», а «одно выбирает формат эмита, другое — способ найти файл на диске, и ни одно не трогает то, что вы написали».
Что обычно проверяют:
- Разницу
moduleиmoduleResolutionи что ни одна опция не меняет исходный код. - Правило «файл-модуль против файла-скрипта» и зачем нужна идиома
export {}. - Что делает
esModuleInteropи чем он отличается отallowSyntheticDefaultImports. - Чем
node16/nodenextотличается отbundlerи почемуnode10идёт под удаление. - Что гарантирует
import typeи зачем он однофайловым транспиляторам подisolatedModules. - Почему
paths-алиас проходит проверку типов, но падает в рантайме. - Когда
namespaceещё уместен и почему в остальных случаях его вытеснили ES-модули. - Как дополнить типы сторонней библиотеки, не форкая её.
- Почему вычисляемый
import()даётanyи как вернуть типобезопасность. - Откуда берутся
@types/*и что на самом деле выключаетskipLibCheck.
Типичный неверный ответ: «import type — это оптимизация, подсказка bundler-у выбросить импорт при tree-shaking». На деле это гарантия про эмит: оператор стирается безусловно, прямо в синтаксисе. Нужен он потому, что однофайловый транспилятор (esbuild, SWC, Babel) видит один файл и без межфайловых типов не может отличить import { User }-тип от import { User }-значения — а tsc может, потому что видит всю программу. Та же путаница слепит кандидата к циклическим импортам: обычный импорт значения, оставленный ради одного типа, создаёт рантайм-ребро, и модуль, вычисляемый вторым, получает ещё пустой exports соседа — class Order extends undefined падает там, где tsc --noEmit был абсолютно чист.