Модули и резолюция
ES-модули против эмита CommonJS, стратегии резолюции модулей (node16, bundler), import type и isolatedModules, namespace, дополнение модулей, динамический import и пакеты @types.
13 вопросов
JuniorТеорияОчень частоОткуда берутся пакеты @types/* и как TypeScript их находит?
Откуда берутся пакеты @types/* и как TypeScript их находит?
Когда JavaScript-пакет не везёт своих типов, сообщество публикует их отдельно как @types/<name>, собранный из репозитория DefinitelyTyped. TypeScript подхватывает их автоматически из node_modules/@types — импортировать их не нужно. Пакету со своими объявлениями @types не нужен вовсе.
Типичные ошибки
- ✗Ставить
@types/xдля библиотеки, которая и так везёт свои объявления - ✗Думать, что из
@types/*нужно что-тоimport-ировать, чтобы они заработали - ✗Считать, что версия
@typesавтоматически следует за версией библиотеки
Уточняющие вопросы
- →Что меняет массив
typesвtsconfig.jsonв этом автоматическом подхвате? - →Как пользоваться библиотекой, у которой нет ни своих типов, ни пакета
@types?
MiddleТеорияОчень частоПочему CommonJS-module.exports = x — не то же самое, что ESM-export default x?
Почему CommonJS-module.exports = x — не то же самое, что ESM-export default x?
module.exports = x заменяет объект модуля — модуль и есть x. export default x добавляет один именованный экспорт default в объект пространства имён, где лежат и все прочие экспорты. Поэтому require() на ES-модуле отдаёт { default: x }, а импорт по умолчанию из CommonJS работает лишь через хелпер interop.
Типичные ошибки
- ✗Считать, что
export defaultкомпилируется в голое присваиваниеmodule.exports = - ✗Забывать, что
require()на ES-модуле отдаёт{ default: x }, а неx - ✗Использовать
export defaultтам, где пакет должен оставаться вызываемым какrequire('m')()
Уточняющие вопросы
- →Почему модуль с
export =не может иметь ещё и именованные ES-экспорты? - →Как
node16решает, является ли конкретный файл CommonJS или ES-модулем?
JuniorТеорияЧастоЧто определяет опция компилятора module, а что — нет?
Что определяет опция компилятора module, а что — нет?
module определяет, какой синтаксис модулей эмитит tsc: commonjs переписывает import/export в require и exports.x, а esnext оставляет их ES-модулями. На то, что вы пишете, опция не влияет. При node16 формат выбирается для каждого файла — по полю type ближайшего package.json.
Типичные ошибки
- ✗Думать, что
moduleменяет синтаксис, который вы пишете, а не тот, что эмититtsc - ✗Путать
module(формат вывода) сtarget(версия JavaScript на выходе) - ✗Считать, что при
node16одно значениеmoduleфиксирует формат для каждого файла
Уточняющие вопросы
- →Почему
nodenextтребует явное расширение файла в относительных импортах? - →Что объявляет
export =и как это должен импортировать потребитель?
JuniorТеорияЧастоЧто такое namespace в TypeScript и почему ES-модули его вытеснили?
Что такое namespace в TypeScript и почему ES-модули его вытеснили?
namespace — доэсэмный способ группировать код: он эмитит IIFE, навешивающий свойства на глобальный объект, а одноимённые блоки сливаются даже из разных файлов. ES-модули вытеснили его: модуль ограничен своим файлом, его импорты статически анализируемы, bundler умеет их тряхнуть. Оставьте namespace только для .d.ts.
Типичные ошибки
- ✗Считать, что
namespaceстирается, какinterface— он эмитит настоящий IIFE и глобальный объект - ✗Организовывать через
namespaceкод приложения, который и так лежит в ES-модулях - ✗Забывать, что одноимённые блоки
namespaceсливаются между файлами, так что инкапсуляции нет
Уточняющие вопросы
- →Когда
declare namespaceвнутри.d.tsвсё ещё правильный инструмент? - →Что позволяет выразить слияние объявлений (
declaration merging) междуnamespaceи функцией?
MiddleТеорияЧастоЧем esModuleInterop отличается от allowSyntheticDefaultImports?
Чем esModuleInterop отличается от allowSyntheticDefaultImports?
allowSyntheticDefaultImports работает только на уровне типов: он гасит ошибку нет экспорта по умолчанию, чтобы import fs from 'fs' прошёл проверку, и ничего не эмитит. esModuleInterop меняет эмит — оборачивает require в __importDefault, чтобы импорт получил module.exports, — и включает первый флаг.
Типичные ошибки
- ✗Включать один
allowSyntheticDefaultImportsи ждать, что импорт заработает в рантайме - ✗Считать, что эти два флага — взаимозаменяемые имена одного поведения
- ✗Упускать, что
esModuleInteropвключаетallowSyntheticDefaultImports, а не наоборот
Уточняющие вопросы
- →Что именно делают в рантайме хелперы
__importDefaultи__importStar? - →Почему
import * as express from 'express'перестаёт быть вызываемым, когда включёнesModuleInterop?
MiddleТеорияЧастоЗачем import type нужен bundler-у, если tsc и так стирает импорты типов?
Зачем import type нужен bundler-у, если tsc и так стирает импорты типов?
tsc видит всю программу и понимает, что имя используется лишь в позиции типа, — импорт вырезается. Однофайловый транспилятор — esbuild, SWC, Babel — видит один файл, без межфайловых типов, и не знает, тип import { User } или значение. import type кладёт ответ в синтаксис.
Типичные ошибки
- ✗Считать, что любой транспилятор вырезает импорты типов так же, как
tsc - ✗Думать, что
import type— подсказка для скорости, а не гарантия про эмит - ✗Оставлять обычный импорт модуля, нужного только ради типов, и втягивать его в бандл
Уточняющие вопросы
- →Что меняет
verbatimModuleSyntaxв том, как эмитятся импорты? - →Как обычный импорт ради типа может создать циклическую зависимость в рантайме?
MiddleТеорияЧастоЧем различаются значения moduleResolution: node10, node16 и bundler?
Чем различаются значения moduleResolution: node10, node16 и bundler?
node10 — легаси-алгоритм CommonJS: идти вверх по node_modules, угадывать расширение, игнорировать карту exports. node16/nodenext моделируют реальный Node: учитывают exports, решают CommonJS или ESM для каждого файла, требуют явное расширение в относительных ESM-импортах. bundler учитывает exports, но разрешает импорты без расширения.
Типичные ошибки
- ✗Оставлять
moduleResolution: node10в пакете, который публикует картуexports - ✗Ставить
bundlerдля кода, который Node запускает напрямую, где импорты без расширения падают - ✗Путать
moduleResolution(поиск файла) сmodule(формат эмита)
Уточняющие вопросы
- →Почему
moduleResolution: node10идёт под удаление и что приходит ему на смену? - →Как карта
exportsпозволяет пакету отдавать разные файлы потребителям на ESM и CommonJS?
MiddleТеорияЧастоПочему bundler может делать tree-shaking ES-модулей, но не CommonJS?
Почему bundler может делать tree-shaking ES-модулей, но не CommonJS?
ESM-import/export — статические объявления: что модуль берёт и что отдаёт, видно из одного синтаксиса, поэтому bundler строит граф, ничего не выполняя, и выбрасывает экспорты без ссылок. require() — обычный вызов, а module.exports изменяем: доказать ненужность нельзя, и остаётся всё.
Типичные ошибки
- ✗Думать, что tree-shaking — про ленивую загрузку, а не про статическую анализируемость
- ✗Публиковать только CommonJS-сборку и ждать, что потребители её вытрясут
- ✗Считать, что
sideEffects: falseспасёт формат, который bundler не может проанализировать
Уточняющие вопросы
- →Как barrel-файл
index.ts, реэкспортирующий всё подряд, на практике ломает tree-shaking? - →Что именно позволяет bundler-у делать
sideEffects: falseвpackage.json?
MiddleТеорияИногдаКак добавить свойство в типы сторонней библиотеки, не форкая её?
Как добавить свойство в типы сторонней библиотеки, не форкая её?
Дополнение модуля: declare module 'lib' { ... } в файле-модуле — в нём нужен import или export верхнего уровня. Ваши интерфейсы тогда сливаются с интерфейсами библиотеки; слияние добавляет члены, но не меняет тип существующего. Без импорта или экспорта файл — скрипт, и блок заменит типы.
Типичные ошибки
- ✗Писать
declare module 'lib'в файле без импорта и экспорта — это заменяет типы, а не сливает их - ✗Пытаться поменять тип уже существующего члена — слияние умеет только добавлять
- ✗Хвататься за
declare global, когда цель — модуль, а не глобал
Уточняющие вопросы
- →Как дополнить
Requestиз Express свойствомuser, которое добавляет middleware? - →Почему дополняющий файл должен быть где-то импортирован, чтобы дополнение применилось?
MiddleКодИногдаТипизация динамического import() со спецификатором, известным лишь в рантайме
Типизация динамического import() со спецификатором, известным лишь в рантайме
С литеральным спецификатором import('./m.js') типизирован — компилятор резолвит файл и выводит Promise<typeof import('./m.js')>. С вычисляемым резолвить нечего, поэтому результат — any, и всё дальше не проверяется. Ожидайте в unknown, сузьте type guard-ом, который смотрит форму в рантайме, и бросайте исключение, если он не прошёл.
Типичные ошибки
- ✗Считать, что спецификатор из шаблонной строки всё ещё резолвится в типизированный модуль
- ✗Приводить импортированный модуль к
Pluginбез рантайм-проверки его формы - ✗Позволять
anyиз вычисляемогоimport()вытечь наружу через тип возврата
Уточняющие вопросы
- →Что именно обозначает
typeof import('./m.js')и когда это можно написать руками? - →Как сохранить типобезопасность реестра плагинов, если плагинам разрешено регистрировать себя самим?
SeniorДебаггингИногдаtsc --noEmit чист, но приложение падает при старте из-за циклического импорта
tsc --noEmit чист, но приложение падает при старте из-за циклического импорта
Типы резолвятся лениво, поэтому циклическая ссылка на тип законна и проверка молчит. Значенческий цикл в эмитируемом JavaScript ей не виден: модуль, вычисляемый вторым, получает ещё пустой exports соседа, и entity_1.Entity в месте extends читается как undefined. Сотрите чисто типовые рёбра через import type, а общее значение вынесите в третий модуль.
Типичные ошибки
- ✗Ждать, что
tscсообщит о цикле, который он по правилам вправе резолвить лениво - ✗Переставлять импорты во входном файле — это лишь меняет, кому достанется пустой
exports - ✗Сваливать все общие типы в один
types.tsвместо того, чтобы стереть чисто типовые рёбра
Уточняющие вопросы
- →Почему
verbatimModuleSyntaxзаставляет этот класс багов проявляться раньше, а не позже? - →Когда значенческий цикл действительно неизбежен и как сделать его безопасным?
SeniorТеорияИногдаЧто запрещает isolatedModules и от какого инструмента он вас защищает?
Что запрещает isolatedModules и от какого инструмента он вас защищает?
Он заставляет tsc отвергать всё, что однофайловый транспилятор не скомпилирует корректно. esbuild, SWC и Babel видят по одному файлу, без межфайловых типов, поэтому реэкспорт типа надо писать export type { T }, а const enum нельзя заинлайнить между файлами. Это контракт совместимости.
Типичные ошибки
- ✗Читать
isolatedModulesкак флаг производительности, а не как контракт совместимости - ✗Считать, что он меняет эмит самого
tsc, тогда как он лишь ограничивает то, что вам можно писать - ✗Ждать, что транспилятор проверит типы, раз
tscпринял файл
Уточняющие вопросы
- →Почему ambient-
const enumнепригоден приisolatedModules? - →Если транспилятор вообще не проверяет типы, зачем тогда остаётся запускать
tsc?
SeniorТеорияИногдаАлиас из paths проходит проверку типов, но падает в рантайме. Чего не хватает?
Алиас из paths проходит проверку типов, но падает в рантайме. Чего не хватает?
paths — алиас только на этапе компиляции. Он говорит проверяющему, куда резолвится @app/x, но в эмите tsc оставляет спецификатор как есть — и Node видит буквальный @app/x и бросает MODULE_NOT_FOUND. Нужен резолвер в рантайме: alias у bundler-а, subpath imports Node или загрузчик tsconfig-paths.
Типичные ошибки
- ✗Считать, что
tscпереписывает алиасные спецификаторы в эмитируемом JavaScript - ✗Настроить алиас только в
tsconfig.jsonи ни разу — в bundler-е или загрузчике - ✗Позволять двум картам алиасов разъехаться, так что одна сборка резолвится, а другая нет
Уточняющие вопросы
- →Почему subpath imports у Node (
#app/*) — более надёжный выбор, чем загрузчикtsconfig-paths? - →Что ломается у потребителя, если опубликовать пакет, в эмитируемом коде которого остались алиасы из
paths?