Menu

Модули в TypeScript: import, export и import type

Любой файл TypeScript с import или export на верхнем уровне является модулем. Именованный экспорт и экспорт по умолчанию, import type и export type, как настройка module выбирает между ES-модулями и CommonJS и почему node16 и nodenext требуют расширение .js в импортах.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Модуль в TypeScript это файл, в котором есть хотя бы один import или export на верхнем уровне. Всё, что в нём объявлено, приватно для файла, пока вы это не экспортируете через export, а другие файлы импортируют через import то, что им нужно. Синтаксис это синтаксис ES-модулей JavaScript плюс несколько форм только для типов.

Именованный экспорт и экспорт по умолчанию

В проекте много файлов, и импортирующая сторона выглядит так. Экспорт по умолчанию импортируется без фигурных скобок и может получить любое имя; именованные экспорты пишутся в скобках и сохраняют свои имена, если не переименовать их через as.

// main.ts
import describe, { distance, ORIGIN, type Point } from "./math.js";
import { distance as dist } from "./math.js"; // renamed on import
import * as math from "./math.js";            // everything, as one object

const p: Point = { x: 6, y: 8 };
console.log(describe(p), distance(ORIGIN, p), dist(p, p), math.ORIGIN);
ФормаЧто импортирует
import { a, b } from "./m.js"именованные экспорты a и b
import x from "./m.js"экспорт по умолчанию под именем x
import * as m from "./m.js"объект пространства имён со всеми экспортами
import { a as b } from "./m.js"a, переименованный в этом файле в b
import type { T } from "./m.js"только типы, удаляется из результата
import "./setup.js"выполняет файл ради побочных эффектов
export { a } from "./m.js"реэкспортирует a, не импортируя его
export * from "./m.js"реэкспортирует все именованные экспорты

Реэкспорт позволяет одному файлу (часто index.ts) собрать публичный API папки. Поведение модулей в обычном JavaScript, например живые привязки и кэширование модулей, описано на странице ES-модули.

import type и export type

Во время выполнения типов не существует, поэтому импорту, который используется только как тип, нечего загружать. import type говорит об этом явно, и инструкция исчезает из JavaScript на выходе. Модификатор type работает и для отдельного имени внутри обычного импорта.

import type { User } from "./models.js";        // whole statement erased
import { saveUser, type Settings } from "./api.js"; // only saveUser survives

export type { User };                            // re-export a type only
export type UserId = User["id"];

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

  • verbatimModuleSyntax: true оставляет каждый импорт, не помеченный type, поэтому непомеченный импорт типа даёт ошибку TS1484: 'Point' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
  • Запуск файлов .ts напрямую в Node (удаление типов) убирает аннотации типов, но не заглядывает в другие файлы. Непомеченный import { Point } остаётся в коде, и Node падает во время выполнения с SyntaxError: The requested module './math.ts' does not provide an export named 'Point'.

Слово type у каждого импорта только типов работает в обоих случаях, так что к нему стоит привыкнуть.

Cannot find module

Когда путь в импорте не ведёт к файлу или пакету с типами, компилятор останавливается с ошибкой TS2307. Запустите этот пример, чтобы её увидеть:

Вывод: index.ts(2,29): error TS2307: Cannot find module './utils.js' or its corresponding type declarations. Обычные причины:

  • Опечатка в относительном пути или пропущенное ./ (без него имя ищется в node_modules).
  • Пакет на JavaScript без встроенных типов: установите @types/{package}, если такой есть, или напишите для него файл объявлений.
  • Подпуть, которого пакет не перечисляет в поле exports своего package.json. При разрешении node16, nodenext и bundler импортировать можно только перечисленные точки входа.

ES-модули или CommonJS на выходе

Вы всегда пишете import и export. Какой JavaScript получится, решает опция module в tsconfig.json.

// Source, the same in both cases
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package with "type": "module"
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package without "type": "module"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const math_js_1 = require("./math.js");
console.log((0, math_js_1.add)(1, 2));

С node16, node18, node20 или nodenext TypeScript следует собственным правилам Node для каждого файла:

ФайлФормат на выходе
.ts в пакете с "type": "module"ES-модуль
.ts в пакете без негоCommonJS
.mtsвсегда ES-модуль, генерируется как .mjs
.ctsвсегда CommonJS, генерируется как .cjs

С module: "esnext" или "preserve" результат сохраняет import/export, чего и ждут бандлеры вроде Vite и esbuild. Различия двух систем модулей во время выполнения описаны на странице CommonJS и ESM.

Расширения файлов в импортах

При module: node16 или nodenext файл ES-модуля должен называть файл, который Node действительно загрузит, а это скомпилированный .js. Для проверки типов TypeScript сопоставляет ./math.js обратно с math.ts.

import { add } from "./math";    // error TS2835 in an ES module file
import { add } from "./math.js"; // correct: the path as it exists after compiling

Текст ошибки: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './math.js'? Файлы CommonJS при тех же настройках могут не указывать расширение, потому что require сам перебирает расширения.

Две другие конфигурации меняют это правило:

  • moduleResolution: "bundler" (с module, равным esnext, preserve или commonjs) принимает ./math без расширения, потому что его разрешает бандлер.
  • rewriteRelativeImportExtensions: true позволяет писать ./math.ts, тот же путь, который работает, когда Node запускает файл .ts напрямую, и переписывает его в ./math.js на выходе.

Разрешение модулей и paths

Для голого имени вроде "zod" TypeScript ищет в node_modules, читает package.json пакета (его поля exports и types) и в крайнем случае обращается к node_modules/@types/zod. Относительные имена (./, ../) разрешаются от импортирующего файла. Импортировать можно и файл .json; нужные для этого настройки описаны на странице JSON.

paths в tsconfig.json добавляет псевдонимы для ваших собственных папок:

{
    "compilerOptions": {
        "module": "esnext",
        "moduleResolution": "bundler",
        "paths": {
            "@lib/*": ["./src/lib/*"]
        }
    }
}

paths влияет только на проверку типов. В сгенерированном файле по-прежнему написано import { v } from "@lib/util", поэтому во время выполнения его должен разрешить кто-то другой: бандлер с тем же псевдонимом или поле imports в package.json для Node (псевдонимы, начинающиеся с #, например "#lib/*": "./dist/lib/*"), которое работает без дополнительных инструментов. В TypeScript 7 baseUrl больше нет (ошибка TS5102); записывайте элементы paths относительно tsconfig.json с ведущим ./.

Часто задаваемые вопросы

Чем import отличается от import type в TypeScript?

import type { User } from "./user.js" может импортировать только типы, и вся инструкция удаляется из JavaScript на выходе. Обычный import может импортировать и значения, и типы; TypeScript отбрасывает имена, которые используются только как типы, но при verbatimModuleSyntax требует помечать их type, чтобы результат совпадал с тем, что вы написали.

Почему TypeScript требует .js в путях импорта?

При module: node16 или nodenext файл ES-модуля должен импортировать по реальному имени файла, который Node загрузит во время выполнения, а это скомпилированный .js. При проверке типов TypeScript сопоставляет ./math.js с math.ts. Пропуск расширения в файле ES-модуля даёт ошибку TS2835.

Что использовать в TypeScript: export default или именованные экспорты?

Работает и то, и другое. Многие команды предпочитают именованные экспорты: имя одинаково в каждом импортирующем файле, редакторы надёжно добавляют их автоимпортом, а переименование становится рефакторингом, а не поиском по тексту. Экспорт по умолчанию позволяет каждому импортирующему файлу выбрать своё имя.

Меняет ли опция paths в tsconfig импорт в результате компиляции?

Нет. paths только сообщает проверке типов, где найти модуль. В сгенерированном JavaScript "@lib/util" остаётся как написано, поэтому во время выполнения его должен разрешить бандлер или поле imports в package.json для Node.

Как исправить «Cannot find module» в TypeScript?

Ошибка TS2307 означает, что путь не ведёт к файлу, который TypeScript может найти, или что пакет не поставляется с типами. Проверьте относительный путь и расширение, установите пакет @types/..., если встроенных типов нет, или напишите для него небольшой файл объявлений.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ