Menu

TypeScript 模块:import、export 与 import type

每个带有顶层 import 或 export 的 TypeScript 文件都是一个模块。本文讲解命名导出和默认导出,import type 与 export type,module 设置如何决定输出 ES 模块还是 CommonJS,以及为什么 node16 和 nodenext 要求导入路径带 .js 扩展名。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

TypeScript 模块就是至少带有一个顶层 import 或 export 的文件。其中声明的一切都是这个文件私有的,除非你 export 它,其他文件再 import 自己需要的内容。语法就是 JavaScript 的 ES 模块语法,再加上几种只用于类型的形式。

命名导出与默认导出

一个项目有很多文件,所以导入的一方是这样写的。默认导出不用花括号导入,可以取任意名字;命名导出写在花括号里,保持原来的名字,除非你用 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.
  • 在 Node 中直接运行 .ts 文件(类型剥离)会去掉类型标注,但不会查看其他文件。未标记的 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} 就安装它,否则为它写一个声明文件。
  • 包的 package.json 中 exports 字段没有列出的子路径。在 node16、nodenext 和 bundler 解析方式下,只有列出的入口才能被导入。

输出 ES 模块还是 CommonJS

你始终写的是 import 和 export,由 tsconfig.json 中的 module 选项决定输出什么样的 JavaScript。

// 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 自己的规则:

文件输出格式
在带 "type": "module" 的包中的 .tsES 模块
在不带它的包中的 .tsCommonJS
.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 页面。

tsconfig.json 中的 paths 可以为你自己的文件夹添加别名:

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

paths 只影响类型检查。生成的文件中仍然写着 import { v } from "@lib/util",所以运行时必须由别的东西来解析它:配置了同样别名的打包工具,或者 package.json 中 Node 的 imports 字段(以 # 开头的别名,比如 "#lib/*": "./dist/lib/*"),后者不需要任何额外工具。TypeScript 7 已经移除了 baseUrl(错误 TS5102);请相对于 tsconfig.json 编写 paths 条目,并以 ./ 开头。

常见问题

TypeScript 中 import 和 import type 有什么区别?

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 还是命名导出?

两种都可以。很多团队更喜欢命名导出:每个导入它的文件里名字都一样,编辑器能可靠地自动导入,重命名也是一次重构而不是全局搜索。默认导出则允许每个导入方自己取名。

tsconfig 中的 paths 选项会改变输出里的 import 吗?

不会。paths 只告诉类型检查器去哪里找模块。生成的 JavaScript 仍然按原样保留 "@lib/util",所以运行时必须由打包工具或 package.json 中 Node 的 imports 字段来解析它。

如何修复 TypeScript 中的“Cannot find module”?

错误 TS2307 表示路径没有指向 TypeScript 能找到的文件,或者某个包没有附带类型。检查相对路径和扩展名;如果包没有内置类型,就安装它的 @types/... 包,或者为它写一个简短的声明文件。

Coddy 编程语言插图

和 Coddy 一起学编程

开始