Модули: import type, verbatimModuleSyntax, moduleResolution
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Строка import { User } from "./user.js" в TypeScript двусмысленна: за фигурными скобками может оказаться тип, значение или и то и другое сразу. Компилятор, который видит всю программу, различает их сам — а компилятор, читающий файл в одиночку (такой стоит внутри сборщика), не может. Отсюда import type, флаг verbatimModuleSyntax и разговор о том, чем заменить доживающий последнюю версию node10.
В части I импорт означал ровно одно: привезти из соседнего файла значение. В TypeScript у имени появилось второе место жительства. Объявления делятся на те, что заводят значение (const, function, class), и те, что заводят только тип (interface, type). Эти два пространства не пересекаются — поэтому одно имя спокойно живёт в обоих сразу.
Компилятор не жалуется: в позиции типа он ищет имя среди типов, в позиции значения — среди значений, и находит в каждой своё. А теперь представьте эти два объявления в соседнем файле. Строка import { Plugin } from "./plugin.js" сама по себе не отвечает, что именно приехало: тип, значение или оба.
Разница между ними видна не в редакторе, а на запуске. До выполнения доезжают только имена, у которых есть значение.
На две последние строки компилятор отвечает одинаково: "Config" относится только к типу, но используется здесь как значение. — код TS2693. Но код всё равно выполнился и напечатал undefined дважды: typeof — единственный оператор, которому позволено спрашивать про несуществующие имена, об этом была речь в главе про всплытие. Компилятор предупредил, JavaScript пожал плечами.
Обратная ловушка: enum выглядит как объявление типа, а заводит настоящий объект. Вычеркнуть его нельзя — про это была отдельная статья.
Двусмысленность компилятор решает самостоятельно: он строит программу целиком, открывает ./user.ts, видит, что User — интерфейс, а VERSION — константа, и при сборке выкидывает из списка импорта первое имя. Приём называется стиранием импортов, import elision.
Пока в списке остаётся хотя бы одно значение, строка импорта живёт. Но если типами оказались все имена, компилятор убирает строку целиком — а вместе с ней и загрузку файла.
Модуль в JavaScript — это не только набор экспортов, но и код, который выполняется при первой загрузке. Регистрация в реестре, установка темы, подключение полифила. Если из соседнего файла вы взяли только тип, всей этой работы не произойдёт.
Ошибку такого рода не видно ни в редакторе, ни в диагностике: типы сходятся, сборка зелёная, просто одна из веток программы молча не выполнилась. Дальше в статье будет две записи, которые загрузку сохраняют.
С другой стороны стирание выглядит как имя-призрак: компилятор про него знает, а в собранном файле его нет. Ровно то же обещание даёт declare — «такое имя есть, поверь на слово», — и последствие видно сразу.
Диагностики нет ни одной — компилятор поверил на слово. А теперь главное: чтобы стереть имя, нужно знать, что лежит в соседнем файле. Половина инструментов в вашей сборке этого не знает. esbuild, swc, Babel и сам Node в режиме node app.ts обрабатывают файл в одиночку и не открывают соседей. Для них import { User } — просто импорт: удалить страшно (вдруг это значение), оставить страшно (вдруг в собранном соседе такого экспорта нет). Флаг isolatedModules как раз и проверяет, что ваш код переживёт однофайловую сборку.
Ответ языка — не заставлять догадываться, а пометить намерение прямо в коде. Пометок две: слово type перед всем списком и модификатор type перед отдельным именем.
Формы выглядят взаимозаменяемыми, но в собранном файле от них остаётся разное — и разница появляется только вместе с флагом из следующего раздела.
| Строка в исходнике | Что остаётся в сборке |
|---|---|
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 там всё сломает.
Пометка type не ослабляет проверку: тип работает ровно так же, как без неё. Меняется только собранный файл.
Флаг переводит компилятор из режима «догадайся» в режим «делай, как написано». Из строки импорта убирается ровно то, что помечено словом type, — и больше ничего. Ни одно имя не исчезает по догадке, ни одна строка не пропадает молча.
Взамен появляется требование к вам: если имя — тип, пометьте его сами.
| Строка в исходнике | Без флага | С флагом |
|---|---|---|
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.
Это те самые import ... = require(...) и export = из статьи про нестираемый синтаксис. В новом коде они не нужны — но если проект наполовину CommonJS, флаг заставит признать это честно, а не оставит на усмотрение компилятора.
Настройка module отвечает за то, что компилятор напишет в собранном файле. Настройка moduleResolution — за то, как он ищет файл по строке в кавычках. Вопросы разные, а путают их постоянно, потому что оба про модули.
Диагностика — TS2307: Не удается найти модуль "./user.js" или связанные с ним объявления типов. Обратите внимание, что компилятор жалуется на поиск, а на выполнение это никак не влияет: аннотация стёрлась, объект остался. И заодно понятно, зачем компилятору вообще открывать соседний файл — за содержимым типа.
Долгие годы по умолчанию работал алгоритм Node образца 2015 года: node, он же node10. Искать ./user.ts, потом ./user/index.ts, потом подниматься по node_modules и читать поле main. Про поле exports в package.json он не знает ничего — а современные пакеты раздают типы именно через него.
В шестой версии история закончилась: 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 в конфиге нет.
Значение 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. Напишите строку импорта так, чтобы компилятор не ругался, а в сборку попало только необходимое. Запустите пример: он подсказывает, у кого из имён есть значение.
Смотрим не на объявление, а на использование. Sort стоит только в аннотации параметра, Product — только в типе возврата: оба существуют лишь в пространстве типов. Catalog зовётся через new, PAGE_SIZE печатается — это значения.
В сборке от строки останется import { Catalog, PAGE_SIZE } from "./catalog.js". Форма import type { ... } здесь не подходит: она пометила бы весь список, и new Catalog() сломался бы на TS1361. Обратите внимание, что Product внутри Catalog нас не касается — этот файл его не видит.
Не потерять побочный эффект
Файл theme.ts при загрузке применяет тему и заодно экспортирует тип. В main.ts из него берут только тип — и тема перестала применяться. Предложите два решения: одно опирается на флаг из статьи, второе работает при любых настройках.
Первое решение — модификатор на отдельном имени. При включённом verbatimModuleSyntax строка не исчезает, а превращается в пустой импорт, и файл загружается.
Второе решение не зависит от настроек и честнее читается: побочный эффект просят отдельной строкой, а тип — своей.
Импорт без фигурных скобок стирать нечем: в нём нет ни одного имени, только требование загрузить файл. Второй вариант предпочтительнее ещё и потому, что намерение видно с первого взгляда — пустой import {} в сборке легко принять за мусор.
Конфиг из чужого проекта
Вам достался проект на TypeScript 5, который собирается Vite. Под шестой версией компилятор перестал запускаться. Что убрать и что оставить?
Достаточно удалить строку moduleResolution целиком: с шестой версии незаданное значение означает bundler — ровно то, что нужно проекту на Vite. Заодно стоит поменять module на preserve, чтобы компилятор не переписывал импорты, и включить verbatimModuleSyntax.
Чего делать не стоит — дописывать "ignoreDeprecations": "6.0". Ошибка замолчит, но в седьмой версии значения не станет вовсе, и разговор повторится в худший момент. И не стоит менять node на nodenext: тогда компилятор потребует расширения .js во всех относительных импортах, а проекту на сборщике это не нужно.