TypeScript 模块与声明文件:ESM、模块解析和 .d.ts
TypeScript 模块与声明文件:ESM、模块解析和 .d.ts
系列导航:TypeScript 7 现代开发指南 · 上一篇 · 下一篇
技术基线:TypeScript 7.0.2
现代 TypeScript 项目通常使用 ECMAScript Modules(ESM),但“源码采用 ESM 语法”并不能单独决定模块如何解析。前端构建工具与 Node.js 对文件扩展名、package.json 和包导出映射的处理不同,module 与 moduleResolution 必须匹配真实运行环境。
本篇先梳理值导入与类型导入,再分别给出 bundler 和现代 Node.js 的配置,并说明声明文件、@types、TypeScript 7 的 types: [] 默认值以及库的声明生成流程。
一、一个文件何时是模块
只要文件包含顶层 import 或 export,它就是模块,顶层声明不会自动进入全局作用域:
// src/math.ts
export const PI = 3.1415926
export function add(left: number, right: number): number {
return left + right
}
export interface Point {
x: number
y: number
}
使用命名导入:
// src/main.ts
import { PI, add, type Point } from './math.js'
const point: Point = { x: add(1, 2), y: PI }
console.log(point)
ESM 还支持默认导出,但公共库通常优先命名导出:名称稳定、重构清楚,也更方便自动补全。
// formatter.ts
export default function format(value: number): string {
return value.toFixed(2)
}
// main.ts
import format from './formatter.js'
示例中的 .js 后缀是现代 Node ESM 的写法:源码文件虽然是 math.ts,运行时产物是 math.js。使用 bundler 时通常也可以写无扩展名的相对导入,但库代码最好遵循目标运行环境和构建工具的约定。
二、import type 与类型专用导出
接口、类型别名等只存在于类型系统,编译后会被擦除。使用 import type 可以明确这条导入不产生运行时依赖:
import type { Point } from './math.js'
export function distance(point: Point): number {
return Math.hypot(point.x, point.y)
}
值与类型来自同一模块时,可以写在一条语句中:
import { add, type Point } from './math.js'
导出也有对应语法:
export type { Point } from './math.js'
export { add, type Point as MathPoint } from './math.js'
推荐开启 verbatimModuleSyntax。开启后,带 type 的导入导出会被删除,未带 type 的语句会按原样保留,源码意图和运行时依赖更容易判断:
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
注意,类和 enum 同时是值与类型。要在运行时调用构造器、读取静态属性、使用 instanceof 或访问枚举成员时,不能只用 import type:
import { User } from './user.js'
const user = new User()
console.log(user instanceof User)
三、module 与 moduleResolution 解决不同问题
module决定 TypeScript 如何解释并输出模块代码;moduleResolution决定编译器如何从导入说明符找到文件、包入口和类型声明。
两者应成对选择,而不是复制一份“万能 tsconfig”:
| 运行方式 | 推荐组合 | 关键特点 |
|---|---|---|
| Vite、Rolldown、webpack、esbuild 等 bundler | module: "esnext" + moduleResolution: "bundler" |
由打包器处理扩展名、别名和最终产物 |
| 现代 Node.js 直接运行编译后的文件 | module: "nodenext" + moduleResolution: "nodenext" |
按 Node ESM/CJS、扩展名和 package.json 规则解析 |
TypeScript 7 不再支持旧的 classic、node / node10 解析策略。升级旧项目时,应先根据真实运行环境迁移,而不是简单删除报错选项。
四、前端构建工具项目:使用 bundler
Vite 一类项目通常由打包器发射 JavaScript,TypeScript 只负责检查:
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src"]
}
bundler 模式理解包的 exports / imports 映射,同时允许打包工具常见的无扩展名相对导入:
import { createApp } from './app'
paths 只告诉 TypeScript 如何检查别名,不会自动修改浏览器或 Node 的运行时解析。配置 @/* 时,还要在 Vite、测试工具或运行器中配置同一别名;如果框架已经生成配置,应优先沿用框架方案。
五、现代 Node.js 项目:使用 nodenext
直接让 Node.js 运行编译后的 ESM 时,建议把 Node 规则交给 nodenext:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
并在 package.json 中声明 ESM:
{
"name": "node-esm-app",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
}
}
需要注意:
type: "module"会让该包范围内的普通.js文件按 ESM 解释;- Node ESM 的相对导入通常必须写运行时扩展名,因此
.ts源码导入./math.js; .mts明确表示 ESM 源文件,.cts明确表示 CommonJS 源文件;对应声明文件是.d.mts与.d.cts;module: "nodenext"应与moduleResolution: "nodenext"配对;- 不要依赖开发运行器恰好支持、而生产 Node 不支持的扩展名省略或路径别名。
六、package.json 的 type 与 exports
发布包时,exports 是公共入口清单。未列出的深层路径会被阻止,因此源码中可访问并不代表消费者可以导入:
{
"name": "@acme/math",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./geometry": {
"types": "./dist/geometry.d.ts",
"import": "./dist/geometry.js"
}
}
}
types 条件应放在相应条件对象前面,让 TypeScript 优先找到声明;顶层 types 可以兼容尚未完整理解 exports 的工具。每个公开子路径都要同时提供运行时代码和匹配的类型入口。
如果一个包同时发布 ESM 与 CommonJS,不要让两种运行时入口随意共用一份声明。声明文件自身也有模块格式:通常应分别提供与 ESM、CJS 入口匹配的 .d.mts、.d.cts,并在条件导出中准确映射。双包发布容易出现构造器身份和模块解析问题,除非确有消费者需求,纯 ESM 包更简单。
七、声明文件 .d.ts 是运行时代码的类型契约
声明文件只描述已有 JavaScript 的类型,不包含实现:
// legacy-math.js
export function add(left, right) {
return left + right
}
// legacy-math.d.ts
export declare function add(left: number, right: number): number
export interface MathOptions {
precision?: number
}
消费者正常导入运行时模块:
import { add } from './legacy-math.js'
const total = add(2, 3)
.d.ts 必须如实反映运行时行为。声明一个实际不存在的导出只能骗过编译器,运行时仍会失败。手写声明时还要保持文件名、模块格式、默认/命名导出和公开子路径一致。
八、为无类型第三方包声明环境模块
某个 JavaScript 包没有自带声明、也没有可用的 @types 时,可以先补充最小声明:
// src/types/tiny-chart.d.ts
declare module 'tiny-chart' {
export interface ChartOptions {
width: number
height: number
}
export function render(
target: HTMLElement,
options: ChartOptions,
): void
}
然后正常使用:
import { render } from 'tiny-chart'
render(document.querySelector('#chart')!, {
width: 800,
height: 450,
})
还可以为资源导入声明通配模块:
// src/types/assets.d.ts
declare module '*.svg' {
const url: string
export default url
}
新建环境模块的 .d.ts 文件通常不应在顶层写 import 或 export;一旦文件自身成为外部模块,其中的 declare module 'name' 通常会被解释为对已有模块的增强,而不是新建声明。需要引用别处类型时,可以使用 import('./types.js').SomeType 形式的内联类型导入。
不要长期使用下面这种兜底:
declare module 'tiny-chart'
它会让整个模块近似 any,应尽快用真实 API 补全。
九、先找包自带类型,再找 @types
使用第三方库时按以下顺序处理:
- 检查包自身的
package.json是否提供types、typings或exports中的types; - 再查询 DefinitelyTyped 上对应的
@types/包名; - 都没有时,在项目内补充最小
.d.ts,并考虑向上游贡献。
例如:
npm install lodash
npm install --save-dev @types/lodash
@types 包应与运行时库的主版本和 API 相匹配。类型包安装成功不代表运行时库已安装,反过来也一样。
十、TypeScript 7 的 types: [] 默认值
TypeScript 7 默认不再把所有可见的 node_modules/@types 包自动注入全局作用域,等价于从更干净的 types: [] 起步。这可以避免测试框架、Node、浏览器扩展等包的全局名字互相污染。
需要某组全局类型时显式列出:
{
"compilerOptions": {
"types": ["node", "vitest/globals"]
}
}
几个容易混淆的边界:
types控制自动加入全局作用域的类型包,不会限制源码可以导入哪些普通模块;lib控制Array、Promise、DOM 等标准环境声明;它与types不是同一个选项;- Node 项目需要安装
@types/node并加入"node"; - Vite 的
import.meta.env等类型可按项目模板加入"vite/client"; - 测试项目可以使用单独的 tsconfig,只在测试范围加入测试框架全局类型。
显式配置环境类型能让生产代码、浏览器代码和测试代码的边界更清楚。
十一、为 TypeScript 库生成声明
库项目不要手工维护所有 .d.ts,应从公开 TypeScript 源码生成:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"verbatimModuleSyntax": true,
"types": []
},
"include": ["src/**/*.ts"]
}
执行:
npx tsc -p tsconfig.build.json
关键选项:
declaration:生成声明文件;declarationMap:生成声明映射,方便消费者跳转到源码;emitDeclarationOnly:只生成类型产物,JavaScript 可交给另一构建流程;rootDir/outDir:保持可预测的目录结构。
tsc 会逐模块生成声明,但不会自动把它们打成一个文件。如果运行时代码由 bundler 合并或改写入口,需要额外验证声明路径仍与最终 exports 一致,必要时使用专门的声明打包工具。
发布前至少在一个临时消费者项目中验证:包能否导入、公开子路径是否可用、默认与命名导出是否一致、声明是否引用了未发布文件。也应检查实际 npm 包内容,而不只检查本地源码。
十二、副作用导入与解析错误
副作用导入没有绑定名称,但仍会在运行时执行模块:
import './polyfills.js'
import './theme.css'
TypeScript 7 默认开启 noUncheckedSideEffectImports,无法解析的副作用导入会报错,避免拼写错误被静默忽略。CSS 等资源应由框架提供的客户端类型或项目内通配声明覆盖,而不是关闭检查。
十三、常见陷阱
- 把
moduleResolution当成运行时加载器:它只影响 TypeScript 如何查找模块,不会让 Node 理解paths别名。 - Node ESM 源码导入
.ts产物:编译后文件通常是.js,相对说明符应按真实运行时写。 - 值被写成
import type:类、枚举或运行时常量会在 JavaScript 中消失,导致无法构造或读取。 package.json与声明格式不一致:type、exports、.d.ts/.d.mts/.d.cts必须共同描述同一种模块格式。exports漏掉子路径:消费者无法导入未公开入口,即使文件确实存在于包内。- 环境模块直接写成
any:短期消除错误,却把风险推迟到运行时。 - 误解
types: []:它清理自动全局类型,不会禁止导入带类型的依赖。 - 只生成声明、不验证发布包:声明可能引用未包含的内部文件,或与 bundler 的最终入口脱节。
小结
ESM 语法只是起点,真正可靠的模块配置必须匹配执行环境:构建工具项目使用 esnext + bundler,现代 Node.js 项目使用成对的 nodenext。import type 和类型专用导出明确区分编译期依赖与运行时依赖,package.json 的 type、exports 和声明入口则共同定义包边界。
TypeScript 7 默认的 types: [] 让全局环境更干净;需要 Node、Vite 或测试框架类型时应显式加入。对于库项目,生成 .d.ts 只是第一步,还必须验证声明格式、导出映射和实际发布内容彼此一致。
官方资料
- TypeScript Modules Reference
- Choosing Compiler Options
- TSConfig: module
- TSConfig: moduleResolution
- TSConfig: types
- Declaration Files
- Publishing Declaration Files
- Node.js Packages