TypeScript 模块与声明文件:ESM、模块解析和 .d.ts

系列导航:TypeScript 7 现代开发指南 · 上一篇 · 下一篇
技术基线:TypeScript 7.0.2

现代 TypeScript 项目通常使用 ECMAScript Modules(ESM),但“源码采用 ESM 语法”并不能单独决定模块如何解析。前端构建工具与 Node.js 对文件扩展名、package.json 和包导出映射的处理不同,modulemoduleResolution 必须匹配真实运行环境。

本篇先梳理值导入与类型导入,再分别给出 bundler 和现代 Node.js 的配置,并说明声明文件、@types、TypeScript 7 的 types: [] 默认值以及库的声明生成流程。

一、一个文件何时是模块

只要文件包含顶层 importexport,它就是模块,顶层声明不会自动进入全局作用域:

// 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)

三、modulemoduleResolution 解决不同问题

  • 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 不再支持旧的 classicnode / 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"
  }
}

需要注意:

  1. type: "module" 会让该包范围内的普通 .js 文件按 ESM 解释;
  2. Node ESM 的相对导入通常必须写运行时扩展名,因此 .ts 源码导入 ./math.js
  3. .mts 明确表示 ESM 源文件,.cts 明确表示 CommonJS 源文件;对应声明文件是 .d.mts.d.cts
  4. module: "nodenext" 应与 moduleResolution: "nodenext" 配对;
  5. 不要依赖开发运行器恰好支持、而生产 Node 不支持的扩展名省略或路径别名。

六、package.jsontypeexports

发布包时,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 文件通常不应在顶层写 importexport;一旦文件自身成为外部模块,其中的 declare module 'name' 通常会被解释为对已有模块的增强,而不是新建声明。需要引用别处类型时,可以使用 import('./types.js').SomeType 形式的内联类型导入。

不要长期使用下面这种兜底:

declare module 'tiny-chart'

它会让整个模块近似 any,应尽快用真实 API 补全。

九、先找包自带类型,再找 @types

使用第三方库时按以下顺序处理:

  1. 检查包自身的 package.json 是否提供 typestypingsexports 中的 types
  2. 再查询 DefinitelyTyped 上对应的 @types/包名
  3. 都没有时,在项目内补充最小 .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 控制 ArrayPromise、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 等资源应由框架提供的客户端类型或项目内通配声明覆盖,而不是关闭检查。

十三、常见陷阱

  1. moduleResolution 当成运行时加载器:它只影响 TypeScript 如何查找模块,不会让 Node 理解 paths 别名。
  2. Node ESM 源码导入 .ts 产物:编译后文件通常是 .js,相对说明符应按真实运行时写。
  3. 值被写成 import type:类、枚举或运行时常量会在 JavaScript 中消失,导致无法构造或读取。
  4. package.json 与声明格式不一致typeexports.d.ts / .d.mts / .d.cts 必须共同描述同一种模块格式。
  5. exports 漏掉子路径:消费者无法导入未公开入口,即使文件确实存在于包内。
  6. 环境模块直接写成 any:短期消除错误,却把风险推迟到运行时。
  7. 误解 types: []:它清理自动全局类型,不会禁止导入带类型的依赖。
  8. 只生成声明、不验证发布包:声明可能引用未包含的内部文件,或与 bundler 的最终入口脱节。

小结

ESM 语法只是起点,真正可靠的模块配置必须匹配执行环境:构建工具项目使用 esnext + bundler,现代 Node.js 项目使用成对的 nodenextimport type 和类型专用导出明确区分编译期依赖与运行时依赖,package.jsontypeexports 和声明入口则共同定义包边界。

TypeScript 7 默认的 types: [] 让全局环境更干净;需要 Node、Vite 或测试框架类型时应显式加入。对于库项目,生成 .d.ts 只是第一步,还必须验证声明格式、导出映射和实际发布内容彼此一致。

官方资料

系列导航:返回目录 · 上一篇 · 下一篇