Модули: import type, verbatimModuleSyntax, moduleResolution

настройка≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Строка import { User } from "./user.js" в TypeScript двусмысленна: за фигурными скобками может оказаться тип, значение или и то и другое сразу. Компилятор, который видит всю программу, различает их сам — а компилятор, читающий файл в одиночку (такой стоит внутри сборщика), не может. Отсюда import type, флаг verbatimModuleSyntax и разговор о том, чем заменить доживающий последнюю версию node10.

Разделы темы:·

В части I импорт означал ровно одно: привезти из соседнего файла значение. В TypeScript у имени появилось второе место жительства. Объявления делятся на те, что заводят значение (const, function, class), и те, что заводят только тип (interface, type). Эти два пространства не пересекаются — поэтому одно имя спокойно живёт в обоих сразу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор не жалуется: в позиции типа он ищет имя среди типов, в позиции значения — среди значений, и находит в каждой своё. А теперь представьте эти два объявления в соседнем файле. Строка import { Plugin } from "./plugin.js" сама по себе не отвечает, что именно приехало: тип, значение или оба.

Разница между ними видна не в редакторе, а на запуске. До выполнения доезжают только имена, у которых есть значение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На две последние строки компилятор отвечает одинаково: "Config" относится только к типу, но используется здесь как значение. — код TS2693. Но код всё равно выполнился и напечатал undefined дважды: typeof — единственный оператор, которому позволено спрашивать про несуществующие имена, об этом была речь в главе про всплытие. Компилятор предупредил, JavaScript пожал плечами.

Обратная ловушка: enum выглядит как объявление типа, а заводит настоящий объект. Вычеркнуть его нельзя — про это была отдельная статья.

Двусмысленность компилятор решает самостоятельно: он строит программу целиком, открывает ./user.ts, видит, что User — интерфейс, а VERSION — константа, и при сборке выкидывает из списка импорта первое имя. Приём называется стиранием импортов, import elision.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пока в списке остаётся хотя бы одно значение, строка импорта живёт. Но если типами оказались все имена, компилятор убирает строку целиком — а вместе с ней и загрузку файла.

Модуль в JavaScript — это не только набор экспортов, но и код, который выполняется при первой загрузке. Регистрация в реестре, установка темы, подключение полифила. Если из соседнего файла вы взяли только тип, всей этой работы не произойдёт.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибку такого рода не видно ни в редакторе, ни в диагностике: типы сходятся, сборка зелёная, просто одна из веток программы молча не выполнилась. Дальше в статье будет две записи, которые загрузку сохраняют.

С другой стороны стирание выглядит как имя-призрак: компилятор про него знает, а в собранном файле его нет. Ровно то же обещание даёт declare — «такое имя есть, поверь на слово», — и последствие видно сразу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностики нет ни одной — компилятор поверил на слово. А теперь главное: чтобы стереть имя, нужно знать, что лежит в соседнем файле. Половина инструментов в вашей сборке этого не знает. esbuild, swc, Babel и сам Node в режиме node app.ts обрабатывают файл в одиночку и не открывают соседей. Для них import { User } — просто импорт: удалить страшно (вдруг это значение), оставить страшно (вдруг в собранном соседе такого экспорта нет). Флаг isolatedModules как раз и проверяет, что ваш код переживёт однофайловую сборку.

Ответ языка — не заставлять догадываться, а пометить намерение прямо в коде. Пометок две: слово type перед всем списком и модификатор type перед отдельным именем.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Формы выглядят взаимозаменяемыми, но в собранном файле от них остаётся разное — и разница появляется только вместе с флагом из следующего раздела.

Строка в исходникеЧто остаётся в сборке
import { User } from "./user.js", где User — типничего: строка исчезает молча, вместе с загрузкой файла
import type { User } from "./user.js"ничего, но это написано вами, а не выведено компилятором
import { type User } from "./user.js"import {} from "./user.js" — файл всё-таки загрузится (только при verbatimModuleSyntax)
import { type User, VERSION } from "./user.js"import { VERSION } from "./user.js"
import "./user.js"остаётся всегда: стирать в этой строке нечего

Обратная сторона пометки: помеченное имя перестаёт быть значением. Попытка позвать new Session() после import type { Session } даёт TS1361: "Session" невозможно использовать как значение, так как он был импортирован с помощью "import type". У экспорта есть двойник TS1362 для export type, а при повторном экспорте типа компилятор просит явную форму: Для повторного экспорта типа при включенном "verbatimModuleSyntax", необходимо использовать "export type". — TS1205.

Практическое правило короткое: спросите себя, встречается ли имя где-нибудь, кроме позиции типа. new, вызов, instanceof, extends в объявлении класса, обращение к статическому полю — это позиции значения, и import type там всё сломает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пометка type не ослабляет проверку: тип работает ровно так же, как без неё. Меняется только собранный файл.

Флаг переводит компилятор из режима «догадайся» в режим «делай, как написано». Из строки импорта убирается ровно то, что помечено словом type, — и больше ничего. Ни одно имя не исчезает по догадке, ни одна строка не пропадает молча.

Взамен появляется требование к вам: если имя — тип, пометьте его сами.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
Строка в исходникеБез флагаС флагом
import { User }, где User — типстрока исчезает молчаTS1484
import type { User }строка исчезаетстрока исчезает
import { type User }строка исчезаетimport {} from …
export { User }, где User — типexport {}TS1205

Третья строка таблицы и есть лекарство из врезки про пропавший побочный эффект: import { type User } from "./theme.js" оставляет в сборке пустой импорт, файл загружается, тема применяется. Без флага та же запись стирается целиком — компилятор всё ещё считает, что знает лучше.

Что флаг даёт в сумме. tsc и однофайловые компиляторы перестают расходиться: esbuild видит в файле ровно ту же информацию, что и tsc, и выдаёт тот же результат. Побочные эффекты модулей больше не пропадают. И читатель кода видит намерение автора, не открывая соседний файл, — в ревью это ценнее, чем кажется. Заготовка из tsc --init, с которой начиналась глава, включает флаг сама; отдельно решать «а нужно ли» в новом проекте не приходится.

«Слово в слово» работает в обе стороны. Если файл по правилам Node оказался модулем CommonJS — расширение .cts или отсутствие "type": "module" в package.json, — то писать в нём синтаксис ECMAScript под этим флагом уже нельзя: компилятор не станет молча переводить import в require.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это те самые import ... = require(...) и export = из статьи про нестираемый синтаксис. В новом коде они не нужны — но если проект наполовину CommonJS, флаг заставит признать это честно, а не оставит на усмотрение компилятора.

Настройка module отвечает за то, что компилятор напишет в собранном файле. Настройка moduleResolution — за то, как он ищет файл по строке в кавычках. Вопросы разные, а путают их постоянно, потому что оба про модули.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика — TS2307: Не удается найти модуль "./user.js" или связанные с ним объявления типов. Обратите внимание, что компилятор жалуется на поиск, а на выполнение это никак не влияет: аннотация стёрлась, объект остался. И заодно понятно, зачем компилятору вообще открывать соседний файл — за содержимым типа.

Долгие годы по умолчанию работал алгоритм Node образца 2015 года: node, он же node10. Искать ./user.ts, потом ./user/index.ts, потом подниматься по node_modules и читать поле main. Про поле exports в package.json он не знает ничего — а современные пакеты раздают типы именно через него.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В шестой версии история закончилась: node10, его синоним node и совсем древний classic объявлены нерекомендуемыми. Конфиг с ними падает ошибкой TS5107: Параметр "moduleResolution=node10" является нерекомендуемым и перестанет работать в TypeScript 7.0. — заглушить её можно только строкой "ignoreDeprecations": "6.0", и то до седьмой версии. Кстати, если написать "node", в тексте ошибки всё равно будет node10: компилятор переименовал значение и говорит новым именем.

ЗначениеКомуЧто важно
nodenextкод, который запускает сам Nodeсмотрит на "type" в package.json и на расширение файла; относительные пути требуют расширения
bundlerкод, который собирает Vite, webpack, Next, esbuildпонимает exports, расширение не требует
node10, nodeстарые проектыне знает про exports; с 6.0 ошибка, в 7.0 удалён
classicникомуэпоха до node_modules; тоже нерекомендуем

Расширения — главная неожиданность при переезде на nodenext. Импорт "./user" перестаёт работать: TS2835 — Относительные пути импорта требуют явных расширений файлов при импорте ECMAScript, если "--moduleResolution" имеет значение "node16" или "nodenext". Вы хотели использовать "./user.js"? Именно .js, а не .ts: в строке импорта пишется имя файла, который получится после сборки. Написать "./user.ts" тоже нельзя — TS5097 попросит включить allowImportingTsExtensions.

Последнее, что стоит знать про эту пару настроек, — они связаны. При "module": "nodenext" компилятор требует такое же значение у moduleResolution или не требует ничего (TS5109), а bundler отказывается работать с nodenext вовсе (TS5095). Поэтому практика простая: задавайте module, а moduleResolution не трогайте вообще. С шестой версии незаданное значение означает bundler — а для nodenext подставится nodenext.

Код запускает Node — "module": "nodenext" и расширения .js в импортах. Код уходит в сборщик — "module": "preserve": компилятор оставит ваши импорты как есть, а разрешение само станет bundler. В обоих случаях строки moduleResolution в конфиге нет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Значение preserve заодно разрешает в одном файле и import, и require — удобно, пока проект переезжает. Полный разбор остальных полей конфига — в первой статье главы.

  • Имя в TypeScript живёт в двух пространствах: типов и значений. До выполнения доезжают только имена, у которых есть значение.
  • Компилятор сам вычёркивает из импорта то, что оказалось типом; если типами были все имена, исчезает вся строка — вместе с побочным эффектом соседнего файла.
  • import type { X } помечает весь список, import { type X } — одно имя; вторая форма при verbatimModuleSyntax оставляет пустой import {} и сохраняет загрузку модуля.
  • verbatimModuleSyntax запрещает догадки: непомеченный тип даёт TS1484, зато сборщик получает тот же результат, что и tsc. В новом проекте флаг включён по умолчанию.
  • node10, node и classic в шестой версии дают ошибку TS5107 и исчезнут в седьмой; живых значения два — nodenext для Node и bundler для сборщиков.
  • Задавайте module, а moduleResolution не трогайте: незаданное значение сегодня и есть bundler, а при nodenext подставится nodenext.

1/4Файл user.ts экспортирует интерфейс User и константу VERSION. В main.ts написано import { User } from "./user.js", и User используется только в аннотации. Флага verbatimModuleSyntax нет. Что окажется в собранном main.js?

2/4В файле написано import type { Session } from "./session.js", а ниже — const s = new Session(). Что произойдёт?

3/4В конфиге проекта стоит "moduleResolution": "node". Что скажет TypeScript 6.0?

4/4Проект собирается esbuild. Зачем ему verbatimModuleSyntax, если tsc и так умеет стирать импорты?

Расставить пометки

Соседний файл ./catalog.js экспортирует все четыре имени из примера ниже, и в проекте включён verbatimModuleSyntax. Напишите строку импорта так, чтобы компилятор не ругался, а в сборку попало только необходимое. Запустите пример: он подсказывает, у кого из имён есть значение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Смотрим не на объявление, а на использование. Sort стоит только в аннотации параметра, Product — только в типе возврата: оба существуют лишь в пространстве типов. Catalog зовётся через new, PAGE_SIZE печатается — это значения.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В сборке от строки останется import { Catalog, PAGE_SIZE } from "./catalog.js". Форма import type { ... } здесь не подходит: она пометила бы весь список, и new Catalog() сломался бы на TS1361. Обратите внимание, что Product внутри Catalog нас не касается — этот файл его не видит.

Не потерять побочный эффект

Файл theme.ts при загрузке применяет тему и заодно экспортирует тип. В main.ts из него берут только тип — и тема перестала применяться. Предложите два решения: одно опирается на флаг из статьи, второе работает при любых настройках.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первое решение — модификатор на отдельном имени. При включённом verbatimModuleSyntax строка не исчезает, а превращается в пустой импорт, и файл загружается.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второе решение не зависит от настроек и честнее читается: побочный эффект просят отдельной строкой, а тип — своей.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Импорт без фигурных скобок стирать нечем: в нём нет ни одного имени, только требование загрузить файл. Второй вариант предпочтительнее ещё и потому, что намерение видно с первого взгляда — пустой import {} в сборке легко принять за мусор.

Конфиг из чужого проекта

Вам достался проект на TypeScript 5, который собирается Vite. Под шестой версией компилятор перестал запускаться. Что убрать и что оставить?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Достаточно удалить строку moduleResolution целиком: с шестой версии незаданное значение означает bundler — ровно то, что нужно проекту на Vite. Заодно стоит поменять module на preserve, чтобы компилятор не переписывал импорты, и включить verbatimModuleSyntax.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Чего делать не стоит — дописывать "ignoreDeprecations": "6.0". Ошибка замолчит, но в седьмой версии значения не станет вовсе, и разговор повторится в худший момент. И не стоит менять node на nodenext: тогда компилятор потребует расширения .js во всех относительных импортах, а проекту на сборщике это не нужно.