模块系统与声明文件
到目前为止我们写的都是「单个文件里的类型」。真实项目由几百个文件组成,还要引用大量第三方库。这一章解决两个问题:
- 模块怎么组织:TypeScript 如何理解
import/export,ESM 和 CommonJS 混用时会发生什么。 - 类型从哪来:当一个库本身是 JS 写的,TypeScript 怎么知道它的类型。
这是 TS 项目里最容易「报错看不懂」的一块,值得花时间搞清楚。
1. 一个文件什么时候算「模块」
规则很朴素:文件里只要有顶层的 import 或 export,它就是一个模块;否则它是全局脚本,里面声明的变量和类型会污染全局作用域。
// a.ts —— 没有 import/export,这是全局脚本
const shared = 1; // 会和其它脚本文件冲突
// b.ts —— 有 export,这是模块
export const value = 1;如果一个文件只想声明类型、不想导出任何值,但又不希望污染全局,标准做法是加一行空导出:
export {};.d.ts 里的全局脚本恰恰很有用——declare global 之外,直接在全局脚本 .d.ts 里写 declare const __VERSION__: string,就能给构建时注入的全局变量补上类型。
2. ESM 与 CommonJS
2.1 两套模块系统的区别
| CommonJS | ESM | |
|---|---|---|
| 导出 | module.exports = ... | export |
| 导入 | require("x") | import ... from "x" |
| 解析时机 | 运行时、同步 | 编译期静态分析 |
| 循环依赖 | 拿到半成品对象 | 有 TDZ 保护 |
| 顶层 await | 不支持 | 支持 |
| Node 识别方式 | 默认,或 .cjs | package.json 里 "type": "module",或 .mjs |
2.2 esModuleInterop 到底做了什么
CommonJS 模块导出的是「一个对象」,而 ESM 的 import x from "y" 期待的是「一个 default 导出」。二者对不上,于是有了 esModuleInterop。
开启后(module 设为 node16/nodenext 时默认就是开的),TS 会:
- 允许
import express from "express"这种默认导入 CJS 模块; - 在生成的代码里插入
__importDefault辅助函数做包装; - 同时开启
allowSyntheticDefaultImports,让类型检查也接受这种写法。
// 关闭 esModuleInterop 时只能这么写
import * as express from "express";
// 开启后可以写成更自然的
import express from "express";在正确的 ESM 语义下,命名空间对象 import * as x 不是函数,即使底层 CJS 导出的是个函数也不能 x()。这是 module: nodenext 下最常见的报错来源。解决办法是改成默认导入,而不是加 as any。
2.3 NodeNext 下的扩展名规则
当 module 设为 nodenext 且项目是 ESM 时,相对导入必须带扩展名,而且要写 .js 而不是 .ts:
import { helper } from "./utils.js"; // 对应源文件 utils.ts初见很反直觉,但这是对的:TS 只做类型擦除,不改写路径,所以你写的路径必须是编译产物的路径。
3. import type 与 export type
类型在编译后会被完全擦除。如果一个 import 只用于类型,却写成了普通 import,可能导致:
- 打包体积变大(引入了运行时不需要的模块);
- 循环依赖变成真实的运行时循环;
- 副作用模块被意外执行。
所以 TS 提供了显式的类型导入:
import type { User } from "./models.js"; // 整句都是类型,编译后消失
import { type User, createUser } from "./models.js"; // 内联形式,混合导入
export type { User };配合 verbatimModuleSyntax: true,编译器会强制要求:任何只用于类型的导入必须带 type 关键字,否则报错。新项目建议直接打开。
只导入类型时用 import type { ... };同时需要值和类型时用内联 { type A, b }。两者语义一致,选一种风格在团队里统一即可。
4. 路径别名 paths
深层相对路径 ../../../shared/utils.js 既难读又难重构。tsconfig.json 的 paths 可以定义别名:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["packages/shared/src/*"]
}
}
}paths 是告诉 TS 去哪里找类型,它不会改写产物里的 import 路径。运行时能不能解析 @/foo,取决于你的打包器(Vite/webpack/Next.js 各有对应配置)或 Node 的 imports 字段。只配 tsconfig 会得到「类型检查通过但运行时找不到模块」。
5. 声明文件 .d.ts
.d.ts 是只有类型、没有实现的文件。它的作用是给 JS 代码(或非 TS 资源)补上类型信息。
5.1 基本语法
.d.ts 里所有声明都用 declare,且不能有函数体:
// legacy-math.d.ts
declare function add(a: number, b: number): number;
declare const VERSION: string;
declare namespace Legacy {
interface Options { precision: number }
function round(n: number, o?: Options): number;
}
export { add, VERSION, Legacy };5.2 declare module:给无类型的包补类型
某个包没有类型,安装后 TS 报「找不到模块的声明文件」。你可以在项目里建一个 types/shims.d.ts:
declare module "untyped-lib" {
export interface Config { retries: number }
export function connect(url: string, cfg?: Config): Promise<void>;
const _default: { version: string };
export default _default;
}同样的语法还能给非代码资源补类型,这在前端项目里极其常见:
declare module "*.svg" {
const content: string;
export default content;
}
declare module "*.css";5.3 模块增强(Module Augmentation)
想给一个已有类型的库加字段(比如给 Express 的 Request 加 user),用同名 declare module 块 + 接口合并:
import "express";
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; role: string };
}
}关键点:这个文件必须是模块(有顶层 import/export),否则 declare module "x" 会被当成「声明一个新模块」而不是「增强已有模块」。
5.4 declare global:扩展全局
export {};
declare global {
interface Window {
__APP_STATE__: Record<string, unknown>;
}
var __DEV__: boolean;
}注意 declare global 只能出现在模块里,所以前面那行 export {} 不能省。
6. 三斜线指令
在 ESM 普及之前,TS 用三斜线指令表达文件间依赖。今天它主要剩下两个用途:
/// <reference types="node" />
/// <reference lib="dom" />reference types显式引入某个@types包(在.d.ts里声明依赖时常用);reference lib引入某个内置库(如dom、es2022)。
/// <reference path="..." /> 已基本被模块导入取代,新代码不要再用。
默认情况下,node_modules/@types 下的所有包都会被自动加载为全局类型。项目大了以后这会拖慢编译并引入意外的全局声明。可以用 "types": ["node", "vitest"] 精确控制,只加载列出的那几个。
7. @types 生态
社区的 DefinitelyTyped 仓库为几千个 JS 库提供了类型包,命名规则是 @types/包名(带 scope 的包 @scope/name 对应 @types/scope__name)。
引入类型的三种情况:
| 情况 | 做法 |
|---|---|
库自带类型(package.json 有 types 字段) | 直接用,无需额外安装 |
| 库没自带,社区有 | npm i -D @types/xxx |
| 都没有 | 自己写 declare module 补丁 |
@types/xxx 的版本号只跟随库的主版本。库升到 5.x 而 @types 还停在 4.x,会出现「类型说没有这个方法,运行时明明有」的诡异情况。升级依赖时记得一起升类型包。
8. 一个可运行的例子:namespace
namespace 是 ESM 之前 TS 自带的模块化方案。今天在应用代码里已不推荐(应该用 ESM 模块),但在 .d.ts 里描述遗留的全局库时仍然常见,值得认识它的形态。
namespace Geometry {
export const PI = 3.14159;
export function circleArea(r: number): number {
return PI * r * r;
}
export namespace Units {
export const area = "cm^2";
export const length = "cm";
}
// 没有 export 的成员是命名空间私有的
function secret(): string {
return "internal";
}
export function reveal(): string {
return secret();
}
}
console.log("PI =", Geometry.PI);
console.log("面积 =", Geometry.circleArea(2).toFixed(3), Geometry.Units.area);
console.log("私有成员通过导出函数访问:", Geometry.reveal());
// 命名空间可以「声明合并」:同名块会被合并到一起
namespace Geometry {
export function rectArea(w: number, h: number): number {
return w * h;
}
}
console.log("矩形面积 =", Geometry.rectArea(3, 4));
console.log("合并后仍能访问 PI:", Geometry.PI);- 在项目里建一个
types/shims.d.ts,给一个虚构的包tiny-cache补上get/set两个函数的类型声明,并在业务文件里 import 它验证补全生效。 - 打开
verbatimModuleSyntax,把项目里所有只用于类型的 import 改成import type,观察产物体积和编译错误的变化。 - 用
declare global给全局加一个__BUILD_TIME__: string,并解释为什么必须先写export {}。
小结
- 有顶层
import/export才算模块,否则是会污染全局的脚本;export {}可强制成模块 - ESM 与 CJS 的差异靠
esModuleInterop弥合;nodenext下相对导入要带.js扩展名 import type/export type保证类型导入被完全擦除,配合verbatimModuleSyntax强制执行paths只解决类型解析,运行时解析需要打包器或 Node 配合.d.ts用declare描述类型;declare module补包类型或增强已有包;declare global扩展全局- 下一章逐条拆解 tsconfig.json →