module и moduleResolution: как строка импорта становится файлом
`module` и `moduleResolution` решают, как строки import превращаются в файлы — классический баг «работает в редакторе, падает в рантайме». `node16`/`nodenext` применяют правила Node ESM; `bundler` (TS 5.0) их ослабляет. `tsc` не переписывает `paths` — это делает бандлер.
В редакторе всё было зелёным. import { parse } from "./parser" разрешался, автодополнялся, проходил проверку типов. CI собрал. Потом production упал: ERR_MODULE_NOT_FOUND: Cannot find module '/app/dist/parser'. Проект был на ESM ("type": "module"), а Node ESM требует .js-расширение в импорте — но moduleResolution был выставлен в node, старый режим, который это правило не применяет. TypeScript спокойно разрешил путь, который Node никогда бы не загрузил.
Две настройки, две задачи
Когда видишь ERR_MODULE_NOT_FOUND в production на коде, который прошёл проверку типов, виновник почти всегда здесь: две настройки, которые выглядят связанными, но управляют совершенно разными вещами.
module решает, какой синтаксис генерирует TypeScript (require/exports для CommonJS, import/export для ES2020+). moduleResolution решает, по какому алгоритму спецификатор импорта превращается в файл на диске. Это раздельные вещи, и современный совет — задать module, а moduleResolution пусть следует из него:
{
"compilerOptions": {
"module": "nodenext", // генерирует ESM или CJS по package.json "type"
"moduleResolution": "nodenext" // разрешает так, как реально делает Node 16+
}
}Доступные режимы разрешения, от старых к новым:
| moduleResolution | обходит | учитывает package.json exports? | нужен .js в ESM? | когда |
|---|---|---|---|---|
classic | только относительные + ambient | нет | нет | никогда (легаси, до 1.6) |
node (он же node10) | node_modules как CJS require | нет | нет | только старые CJS-проекты |
node16 | CJS или ESM по типу пакета файла | да | да (в ESM-файлах) | Node, зафиксирован на поведении 16 |
nodenext | то же, что node16, следит за новейшим Node | да | да (в ESM-файлах) | Node-библиотеки/приложения сегодня |
bundler (TS 5.0) | exports + без расширений, без правила .js | да | нет | esbuild / Vite / webpack |
Как идёт разрешение: bare-спецификаторы и exports
Для относительного импорта (./parser) резолвер пробует файлы рядом с импортирующим. Для bare-спецификатора (lodash, @scope/pkg) он поднимается по каталогам node_modules, затем смотрит в package.json пакета. При node16/nodenext/bundler он читает поле exports и выбирает цель, сопоставляя условия — import vs require, types, node, default:
// node_modules/@acme/sdk/package.json
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs", // выбран для ESM `import`
"require": "./dist/index.cjs" // выбран для CJS `require`
}
}
}exports — это граница инкапсуляции: если путь не перечислен, его нельзя импортировать, даже если файл существует. import "@acme/sdk/internal/util" падает, если ./internal/util нет в exports. Старый режим node полностью игнорирует exports — именно поэтому пакет разрешается при node, но ломается при nodenext (или наоборот).
Неожиданное правило: .js-расширения в импортах TS
При node16/nodenext в ESM-файле импорт надо писать с расширением .js, хотя исходный файл — parser.ts:
// src/index.ts — ESM (package.json "type": "module")
import { parse } from "./parser.js"; // ✅ разрешает parser.ts, генерирует import "./parser.js"
import { parse } from "./parser"; // ❌ TS2835: относительный импорт требует расширения ".js"
import { parse } from "./parser.ts"; // ❌ TS5097: путь импорта не может оканчиваться на ".ts"Ты пишешь расширение результата (.js), TypeScript проверяет по исходнику (.ts), а emit оставляет нетронутым, чтобы Node нашёл реальный файл в рантайме. Режим bundler отключает это требование, потому что бандлеры сами разрешают импорты без расширений.
▸Почему это работает
Почему именно .js, а не .ts? Потому что правило дизайна TypeScript — он никогда не переписывает спецификаторы импорта в emit (с единственным исключением — смена формы импорта при module: commonjs). Строка, которую ты пишешь, — это строка, которую видит Node. Поскольку Node ESM требует расширение, а файл на диске после компиляции — parser.js, надо писать parser.js. Написать parser.ts — значит сгенерировать parser.ts, которого в рантайме не существует.
Алиасы paths: tsc их проверяет, но не генерирует
baseUrl/paths позволяют писать import { db } from "@/db" вместо ../../../db. Критически важно: tsc разрешает их для проверки типов, но не переписывает в генерируемом JS — литеральная "@/db" остаётся в выводе. В рантайме Node понятия не имеет, что такое @/db.
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }Поэтому paths работает, только если что-то ещё переписывает алиас: бандлер (Vite, esbuild, webpack с подходящим resolve.alias) или загрузчик в рантайме (tsconfig-paths или поле imports в package.json). Классический сбой: проверки типов и сборка через dev-сервер проходят, а голый node dist/index.js бросает Cannot find module '@/db'.
Интероп: esModuleInterop и verbatimModuleSyntax
У CommonJS нет настоящего default-экспорта; module.exports = x — это присваивание всего пространства имён. esModuleInterop: true синтезирует default, чтобы import express from "express" работал против CJS-модуля, и генерирует хелперы __importDefault. Без него понадобилось бы import express = require("express") или import * as express.
verbatimModuleSyntax (TS 5.0, заменивший importsNotUsedAsValues и emit-половину забот isolatedModules) делает emit буквальным: обычный import/export генерируется как есть, а стираются только import type / export type. Это вынуждает явно указывать, что является type-only импортом — именно это нужно однофайловым транспиляторам:
import { type User, createUser } from "./user.js";
// emit: import { createUser } from "./user.js"; — `User` стёрт, `createUser` сохранён дословноРасставь шаги node16/nodenext при разрешении import { x } from '@acme/sdk' в ESM-файле:
- 1 Видит bare-спецификатор @acme/sdk (не относительный), значит ищем в node_modules
- 2 Поднимается по каталогам node_modules, пока не найдёт @acme/sdk/package.json
- 3 Читает поле exports пакета вместо угадывания main/index
- 4 Сопоставляет условие import (ESM-контекст), чтобы выбрать ./dist/index.mjs и его типы
ESM-проект проходит проверку типов в VS Code, но `node dist/index.js` бросает Cannot find module './parser'. В tsconfig стоит moduleResolution: 'node'. В чём корневая причина?
- 01В чём разница между `module` и `moduleResolution` и почему неверный режим даёт «работает в редакторе, падает в рантайме»?
- 02При node16/nodenext почему надо писать `./parser.js`, чтобы импортировать файл parser.ts, и какому общему правилу это подчиняется?
- 03Почему алиасы `paths`/`baseUrl` работают в редакторе и бандлере, но ломаются при голом `node dist/index.js`?
Теперь ты можешь рассуждать о том, как строка импорта становится файлом: module генерирует синтаксис, moduleResolution находит цель, exports/условия гейтят доступ к пакету, правило .js-расширения спотыкает новичков в ESM, а paths нужен бандлер, чтобы пережить emit. Дальше — declaration-файлы: когда код разрешается, как поставлять типы к нему? .d.ts-файлы, ambient- и module-объявления и ловушки аугментации, которые молча не сливаются. Теперь, когда встречаешь ERR_MODULE_NOT_FOUND в production на коде, который редактор принял, первый вопрос — совпадает ли moduleResolution с рантаймом и написано ли расширение .js в относительном импорте?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.