Chapter 07

模块系统与声明文件(.d.ts)

理解 TypeScript 的模块解析机制,掌握声明文件的编写与第三方库类型声明

ES Modules vs CommonJS

两种模块系统的本质区别

JavaScript 历史上存在两套模块系统,它们在语法、加载方式和执行模型上都有根本差异。理解这些差异对于配置 TypeScript 编译器至关重要。

CommonJS (CJS)
Node.js 的历史模块系统。使用 require() / module.exports。模块在运行时同步加载(require 是普通函数调用),可以在条件语句中动态 require。.js 文件默认是 CJS(除非 package.json 中有 "type": "module")。
ES Modules (ESM)
ECMAScript 标准的模块系统。使用 import/export 语句。模块在编译时静态分析(import 必须在顶层),支持 Tree Shaking。浏览器原生支持,Node.js 12+ 支持(需 .mjs 扩展名或 "type": "module")。
TypeScript 的处理
TypeScript 源码始终用 import/export 语法编写。编译时根据 tsconfig 的 module 选项,将其编译为 CJS (commonjs) 或保留为 ESM (esnext/es2020)。这与运行时使用哪种模块系统是分开的概念。

两种模块系统的差异

// ES Modules(推荐,TypeScript 原生支持)
import { createServer } from 'http';
import type { IncomingMessage } from 'http';  // 仅导入类型,编译后删除
export { myFunction };
export default class MyClass {}

// CommonJS(Node.js 传统方式)
const http = require('http');
module.exports = { myFunction };

import type:类型专用导入

// 编译后 import type 完全被删除(不留任何 JS 代码)
import type { User, Post } from './types';
import type * as T from './types';

// 推荐用于:避免循环依赖、明确区分类型与值导入
import { createUser } from './users';       // 值导入
import type { CreateUserInput } from './users';  // 类型导入

声明文件(.d.ts)

为 JS 库编写声明文件

// legacy-lib.d.ts:为纯 JS 库提供类型声明

// declare 关键字:声明已存在但不由 TS 管理的类型/变量
declare function legacyFormat(str: string, options?: { uppercase?: boolean }): string;

declare class LegacyDatabase {
  constructor(connectionString: string);
  query(sql: string): Promise<unknown[]>;
  close(): void;
}

declare const APP_VERSION: string;

// 声明模块
declare module 'some-css-loader' {
  const content: { [className: string]: string };
  export default content;
}

// 扩展全局类型
declare global {
  interface Window {
    analytics: { track(event: string, props?: object): void };
  }
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}

@types/ 包与 DefinitelyTyped

@types 的工作原理

许多 JavaScript 库是用纯 JS 写的,没有内置类型声明。DefinitelyTyped 是一个社区维护的大型 GitHub 仓库(microsoft/DefinitelyTyped),收录了数千个流行库的 .d.ts 声明文件,发布为 npm 上的 @types/xxx 包。安装后 TypeScript 会自动找到并使用这些类型声明。

内置类型的库(bundled types)
现代库通常自带类型声明,package.json 中有 "types" 或 "typings" 字段指向 .d.ts 文件。无需额外安装 @types 包。例:axios、zod、prisma、@tanstack/react-query。
DefinitelyTyped 提供类型的库
老牌纯 JS 库没有内置类型,需要单独安装对应的 @types/xxx 包。例:@types/lodash、@types/express、@types/node、@types/react(React 本身也需要)。
无类型声明的库
少数库既无内置类型也无 @types 包。解决方案:1) 在项目中创建 declarations.d.ts 手写声明;2) 用 declare module 'xxx' 临时声明为 any;3) 贡献类型到 DefinitelyTyped。
# 为 Express 安装类型声明
npm install express
npm install --save-dev @types/express

# 为 Node.js 内置模块安装类型(fs/path/http 等)
npm install --save-dev @types/node

# 查询某个包是否有 @types:
# https://www.typescriptlang.org/dt/search 或
npm info @types/lodash  # 如果有输出则存在

声明文件的查找顺序

TypeScript 在引入一个模块时,按以下顺序查找类型声明:

// 当你写 import 'lodash' 时,TypeScript 按序查找:
// 1. node_modules/lodash/package.json 的 "types" 或 "typings" 字段
// 2. node_modules/lodash/index.d.ts
// 3. node_modules/@types/lodash/index.d.ts
// 4. 如果都找不到,该模块类型为 any(在 strict 模式下会报错)

// 可以在 tsconfig.json 中指定 typeRoots 来控制查找位置
// 也可以用 types 数组只引入特定的 @types 包

路径别名(paths)

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"],
      "@types/*": ["src/types/*"]
    }
  }
}
// 使用路径别名(代替冗长的相对路径)
import { Button } from '@components/Button';  // 而不是 ../../components/Button
import { formatDate } from '@utils/date';

moduleResolution 策略

bundler(推荐,TS 5.0+)
为 Vite/Webpack/Rollup 等打包器优化。支持无扩展名导入、exports 字段、路径别名。现代项目的首选。
node16 / nodenext
适合 Node.js ESM 项目。导入时必须写明 .js 扩展名(即使文件是 .ts)。Node.js 18+ 原生 ESM 项目使用。
node(旧版)
CommonJS 项目的传统设置。支持省略扩展名,但不支持 package.json 的 exports 字段。
现代项目推荐配置

使用 Vite/Bun/Turbopack 等现代打包器的项目,推荐 moduleResolution: "bundler"module: "ESNext"target: "ESNext"。配合 verbatimModuleSyntax: true 强制区分类型导入和值导入。

命名空间(namespace)与模块的关系

TypeScript 早期使用 namespace(曾叫 module)来组织代码,在 ES Modules 出现前是唯一的代码隔离方式。现在大多数场景应该使用 ES Modules,但 namespace 在某些特定场景仍然有用。

namespace 的适用场景
1) 扩展第三方库的类型声明(declare namespace Express);2) 声明全局变量的嵌套结构(declare namespace NodeJS);3) 在 .d.ts 文件中组织复杂的类型定义。
不推荐在应用代码中使用 namespace 的原因
namespace 会生成 IIFE 代码,难以 Tree Shake;ES Modules 已经提供了更好的代码隔离;打包器对 ES Modules 的优化远优于 namespace;团队协作时 namespace 嵌套层次容易造成混乱。
// .d.ts 文件中使用 namespace 扩展第三方类型(合法场景)
declare namespace Express {
  interface Request {
    // 扩展 req 对象,添加认证用户信息
    user?: {
      id: string;
      email: string;
      roles: string[];
    };
    sessionId?: string;
  }
}

// 扩展 NodeJS 进程环境变量类型(配合 dotenv 使用)
declare namespace NodeJS {
  interface ProcessEnv {
    // 将 process.env 的访问变为类型安全
    DATABASE_URL: string;
    NODE_ENV: 'development' | 'production' | 'test';
    PORT?: string;
    JWT_SECRET: string;
  }
}

声明文件作者的最佳实践

为 JavaScript 库手写高质量 .d.ts

// legacy-lib.d.ts — 为第三方 JS 库编写声明文件的完整示例

// 1. 函数重载:同名函数不同参数的精确类型
declare function parseDate(s: string): Date;
declare function parseDate(timestamp: number): Date;
declare function parseDate(s: string, format: string): Date;

// 2. 可调用对象(函数对象,同时有方法)
interface LegacyLib {
  // 作为函数调用
  (config: { debug?: boolean; timeout?: number }): void;
  // 同时有属性
  version: string;
  configure(options: Record<string, unknown>): void;
}
declare const legacyLib: LegacyLib;

// 3. 全局变量和命名空间混合(UMD 库的声明方式)
export as namespace myLib;  // 允许 window.myLib 全局访问
export = myLib;              // CommonJS: require('myLib') 的类型
declare function myLib(selector: string): HTMLElement[];
declare namespace myLib {
  ajax(url: string): Promise<unknown>;
}
export = 与 export default 的区别

export = module 是 TypeScript 的 CJS 模块导出语法,对应 module.exports = ...。使用 export = 的模块在导入时必须用 import x = require('module')(CJS 风格)或 import x from 'module'(需要 esModuleInterop: true)。不能在同一个文件中同时使用 export = 和普通的 export { ... }——这是"默认导出"风格和"命名空间导出"风格的根本冲突。

三斜线指令(Triple-Slash Directives)

三斜线指令是特殊的单行注释,只在 .d.ts 文件或文件顶部使用,用于声明文件间的依赖关系。在现代项目中已很少需要手动写三斜线指令,但理解它们有助于读懂老项目和 DefinitelyTyped 的声明文件。

/// <reference types="node" />
// 等同于在 tsconfig 的 types 中添加 "node"
// 告诉 TS:这个文件依赖 @types/node 的类型声明

/// <reference path="./globals.d.ts" />
// 引用另一个 .d.ts 文件,建立依赖关系
// 现代项目中应使用 import 代替

/// <reference lib="dom" />
// 包含内置 lib 声明(如 DOM 类型)
// 在 tsconfig 的 lib 选项中设置更推荐

// 注:这些指令必须写在文件的最顶部,在任何代码和普通注释之前

本章小结

本章核心要点