open atlas
↑ К треку
Система типов TypeScript вглубь TS · 07 · 02

module и moduleResolution: как строка импорта становится файлом

`module` и `moduleResolution` решают, как строки import превращаются в файлы — классический баг «работает в редакторе, падает в рантайме». `node16`/`nodenext` применяют правила Node ESM; `bundler` (TS 5.0) их ослабляет. `tsc` не переписывает `paths` — это делает бандлер.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

В редакторе всё было зелёным. 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-проекты
node16CJS или 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. 1 Видит bare-спецификатор @acme/sdk (не относительный), значит ищем в node_modules
  2. 2 Поднимается по каталогам node_modules, пока не найдёт @acme/sdk/package.json
  3. 3 Читает поле exports пакета вместо угадывания main/index
  4. 4 Сопоставляет условие import (ESM-контекст), чтобы выбрать ./dist/index.mjs и его типы
Викторина

ESM-проект проходит проверку типов в VS Code, но `node dist/index.js` бросает Cannot find module './parser'. В tsconfig стоит moduleResolution: 'node'. В чём корневая причина?

Вспомните перед уходом
  1. 01
    В чём разница между `module` и `moduleResolution` и почему неверный режим даёт «работает в редакторе, падает в рантайме»?
  2. 02
    При node16/nodenext почему надо писать `./parser.js`, чтобы импортировать файл parser.ts, и какому общему правилу это подчиняется?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.