TypeScript · 声明文件与模块扩展

d.ts 是"形状说明书": 消费方只看它不看实现 — declare 描述世界, merge 扩展它

发布视图: 消费方只看 .d.ts src/index.ts 实现 + 类型标注 tsc 自动生成声明 tsc -d dist/index.js 剥掉类型的产物 dist/index.d.ts 消费方类型来源 // package.json — 解析链的第一站 "types": "./dist/index.d.ts", "exports": { ".": { "types": ..., "import": ... } } 没有 types 字段 → @types/lodash 这类"社区说明书"兜底 declare 家族: 描述"别处存在的东西" declare module 'untyped-legacy' { export function init(cfg?: Record<string, unknown>): void; } declare global { // 给"全局"加东西(如 window) interface Window { __APP_VERSION__: string; } } declare const VERSION: string; // 构建注入的全局常量 declare module '*.css'; // 非 JS 资源占位 共同点: 只描述形状不给实现 — 实现"在别处"(运行时/构建器/宿主) // 模块扩展(module augmentation): 给已有类型"加字段", 两边声明自动合并 import type { Request } from 'express'; declare module 'express-serve-static-core' { interface Request { user?: { id: number; role: 'admin' | 'user' }; // 中间件挂的登录态 } } // 之后所有 req.user 都有完整类型 — 路由里不用再 as any // 前提: 该文件必须是模块(有 import/export), 且扩展目标路径写对

d.ts 是契约面

  • • 消费方编译只看声明不看实现
  • • 发布: types 字段 → index.d.ts → @types
  • • 从源码生成, 别手写
  • • d.ts 与实现脱节 = 用户侧炸

declare 描述世界

  • • declare module 补无类型老库
  • • declare global 扩 Window 等
  • • declare const 描述构建注入
  • • *.css/*.png 资源模块占位

merge 扩展生态

  • • 模块扩展给库类型加字段
  • • Request.user / Vue 组件全局属性
  • • 必须"模块文件"里写
  • • 扩展目标路径要对准

💡 一句话理解

.d.ts 是一份只写形状不给实现的说明书: 你的库发布后, 消费方的编译器不看你那 8000 行实现, 只看这份说明书来决定类型对不对。declare 家族则是"向编译器宣告别处存在的东西" — 这个全局变量是构建器注入的、这个老库没有类型但长这样、这个 *.css 导入是合法的。

而声明合并是这套体系的放大器: interface 天生可叠加, 于是你能"钻进"第三方库的类型里加字段 — 给 Express 的 Request 挂上 user, 给 Window 挂上全局常量。三个词概括本页: 说明书(d.ts)、宣告(declare)、合并(merge)。

🧠 必知必会 必考 & 必会

d.ts 的本质
只有类型签名没有实现的"头文件"; 消费方 tsc 只依据它报错, 与你的实现是否一致它管不着。
// dist/index.d.ts
export declare function format(n: number): string;
// 没有 { } 实现体 — 只有形状
types 解析链
package.json 的 exports/types/typings → 包内 index.d.ts → @types/<pkg> 兜底。
npm i lodash
npm i -D @types/lodash   # 社区说明书, 不装就 any 化
declare module
为"没有类型的模块"补最小契约; 字符串字面量名对应包名。
declare module 'legacy-widget' {
  export function mount(el: HTMLElement, opts?: object): void;
}
declare global
在模块文件里扩展全局作用域(Window/process 上的自定义); 必须有 import/export 才算模块。
export {};                    // 让文件变模块
declare global {
  interface Window { __ENV__: 'dev' | 'prod'; }
}
declare const/let
宣告"运行时存在这个全局值": 构建器 DefinePlugin 注入、CDN 脚本变量。
declare const __APP_VERSION__: string;
console.log(__APP_VERSION__);   // 编译认账, 实现由构建器给
资源模块占位
*.css/*.png/*.svg 导入在 TS 里默认不存在, 用通配 declare module 补位。
declare module '*.module.css' {
  const classes: Record<string, string>;
  export default classes;
}
模块扩展
对"已有模块"再开一次 interface 同名声明, 字段自动合并进原类型。
import 'vue';
declare module 'vue' {
  interface ComponentCustomProperties {
    $api: ApiClient;
  }
}
声明合并范围
同全局/同模块的同名 interface/namespace 合并; type 别名不合并。
interface Box { a: 1 }
interface Box { b: 2 }
const b: Box = { a: 1, b: 2 };   // 合并生效
export = 互操作
老 CJS 库用 export = 整体导出; ESM 消费用 esModuleInterop/allowSyntheticDefaultImports。
{ "esModuleInterop": true }
import fs from 'fs';   // ✓ 默认导入 CJS 整包
三斜线指令
/// reference types="node" 用于全局脚本/声明文件引类型包; 模块代码里直接 import type。
// scripts/global.d.ts (无 import/export 的脚本文件)
/// <reference types="vite/client" />
ambient 风险
全局 d.ts 无 import/export 即" ambient": 声明全局注入, 一处写错全仓污染。
// env.d.ts 忘写 export {} → 里面的 interface 变全局
消费侧验证
库发布前, 在干净目录 npm pack + 安装 + tsc 编译一个测试项目 — 声明好不好用只有消费方知道。
npm pack
# ../check-project: npm i ../mylib-1.0.0.tgz && tsc --noEmit

🏭 生产实战 real world

场景 1 · 老库没类型: 最小 declare module 兜底

核心依赖的私有 SDK 是裸 JS, import 后全 any。按"我们用到什么补什么"写最小声明:

// types/legacy-sdk.d.ts
declare module '@corp/legacy-sdk' {
  export interface SdkOpts { endpoint: string; timeout?: number }
  export class Client {
    constructor(opts: SdkOpts);
    query<T>(sql: string): Promise<T[]>;
    close(): void;
  }
}
// 原则: 只声明用到的面, 库升级再补 — 别猜全貌

场景 2 · Express 挂登录态: 模块扩展 Request

auth 中间件塞的 req.user 在路由里全是 any。augmentation 一次, 全仓受益:

// types/express.d.ts
import type { User } from '../src/auth';
declare global {
  namespace Express {
    interface Request { user?: User }
  }
}
// 路由里: req.user?.id  直接有完整类型

场景 3 · 库发布: 声明 + exports 完整链

用户报"没有类型定义"。检查发布三件套是否齐全:

{ "types": "./dist/index.d.ts",
  "exports": { ".": {
    "types": "./dist/index.d.ts",      // 条件里也要有!
    "import": "./dist/esm/index.js" } },
  "files": ["dist"] }
# 本地验证: npm pack → 干净目录安装 → tsc --noEmit

场景 4 · 全局环境变量: ImportMetaEnv 定型

import.meta.env.VITE_API 全是 any。给 vite/client 的类型扩展补上字段:

// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API_BASE: string;
  readonly VITE_SENTRY_DSN?: string;
}
interface ImportMeta { readonly env: ImportMetaEnv }

场景 5 · monorepo 内部包: 直接引源码类型

内部包不想为了类型走构建产物。tsconfig paths 指向源码入口:

{ "paths": {
    "@repo/ui": ["../ui/src/index.ts"] } }
// 类型随源码实时更新, 不存在 d.ts 漂移
// 发布给外部时才走构建 + d.ts

场景 6 · 第三方类型错了: 覆盖而非 fork

@types 某字段标错, 全仓跟着错。轻量方案: 局部 augmentation 纠偏, 或 patch 包:

// 方案 A: 扩展修正(加正确形状, 绕开错的)
declare module '@types-heavy/lib' {
  interface Opts { retries: number; }  // 合并覆盖语义
}
// 方案 B: pnpm patch 官方包的 d.ts, CI 锁补丁

场景 7 · JSON 导入类型

直接 import 配置 JSON, resolveJsonModule 给出字面量推断:

{ "resolveJsonModule": true, "moduleResolution": "bundler" }
import pkg from '../package.json';
pkg.version;   // 类型是字面量, 不是 any

场景 8 · CSS Modules / 图片资源声明

import styles from './x.module.css' 报红。资源声明文件一次到位:

// types/assets.d.ts
declare module '*.module.css' {
  const c: Record<string, string>;
  export default c;
}
declare module '*.svg' {
  const url: string;
  export default url;
}

场景 9 · 枚举/命名空间文档增强

给既有枚举补 helper 命名空间, 声明合并让调用面更顺:

enum OrderStatus { Paid, Shipped }
namespace OrderStatus {
  export function isFinal(s: OrderStatus) {
    return s === OrderStatus.Shipped;
  }
}
OrderStatus.isFinal(OrderStatus.Paid);  // 同名合并出 helper

场景 10 · 消费侧 CI: 声明质量门禁

发布后发现 d.ts 引用了没发布的内部类型。流水线加"安装即编译"关卡:

# ci/verify-types.sh
npm pack
mkdir -p /tmp/consumer && cd /tmp/consumer
npm init -y && npm i ../mylib-*.tgz typescript
echo 'import { api } from "mylib"; api.users();' > t.ts
npx tsc --noEmit t.ts   # 声明不完整这里当场爆

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 手写 d.ts 与实现脱节 — 声明说有实则无, 用户编译过运行炸. 正解: tsc 从源码生成, 禁手写。
{ "emitDeclarationOnly": true }  # 强制走生成
坑 2 · 全局声明忘了模块化 — .d.ts 无 import/export 时内容变全局, 意外污染. 正解: 需要隔离就 export {}。
// env.d.ts 末尾: export {};  让声明局部化
坑 3 · augmentation 不生效 — 写在无 import 的全局文件里, 变 ambient 合并到错的位置. 正解: 扩展文件必须是模块, 目标模块名对准。
// 对: import 'express'; declare module 'express-serve-static-core' {...}
坑 4 · 扩展目标路径错 — declare module 'express' 但类型在 express-serve-static-core, 静默无效. 正解: 悬停看真正的来源模块名。
// IDE: cmd+click req.user → 看它声明在哪个模块
坑 5 · Window 随便挂 — 全局注入无节制, 与 SSR/微前端冲突. 正解: 单一命名空间 + declare global 收口。
// 对: window.__APP__?.user 而不是 window.user
坑 6 · @types 版本错配 — 库 v3 配 @types v2, 类型缺方法. 正解: 锁 @types 版本与库同步升级。
npm i -D @types/node@20   # 跟 node 大版本走
坑 7 · 双份声明冲突 — 库自带 types 又装了 @types, 接口对撞. 正解: 卸掉社区版或 patch 其一。
npm rm @types/vue   # vue 3 自带类型
坑 8 · d.ts 相对导入丢后缀 — 声明文件内部 import 指向不存在文件, 发布后断链. 正解: 生成而非手写, 或路径核对。
// 手写 d.ts: import { U } from './user' ← user.d.ts 必须真存在
坑 9 · 三斜线用错地方 — 模块代码里写 /// reference 无效(应 import type). 正解: 脚本/声明文件才用三斜线。
// 模块里: import type {} from 'vite/client'; # 或 reference 也行但更推荐 paths
坑 10 · declare const 与实际不符 — 声明了构建注入但构建没注入, 运行时 ReferenceError. 正解: 声明与构建配置双向核对。
new DefinePlugin({ __APP_VERSION__: JSON.stringify(v) })
坑 11 · namespace 全局残留 — 老代码 UMD namespace 模式与现代 ESM 打架. 正解: 新代码一律 ES 模块, namespace 只留类型用途。
namespace Utils {}   # 别再当模块用
坑 12 · export = 互操作崩 — CJS 老包 export = class, ESM 默认导入失败. 正解: esModuleInterop: true。
{ "esModuleInterop": true }   # 现代 tsconfig 默认带
坑 13 · types 字段路径错 — 发布后用户报 "no declaration file", 本地却好好的. 正解: exports 条件里同步 types + npm pack 实测。
// exports "." 条件里没有 types → bundler 模式下丢失
坑 14 · barrel d.ts 循环 — 大 index.d.ts 互相引用成环, 消费方编译慢/报错. 正解: 声明按文件生成, barrel 只 re-export。
// 生成器(如 api-extractor) 拍平引用
坑 15 · ambient enum 重复 — 两个 d.ts 同名 enum 值不同, 合并后行为诡异. 正解: enum 收敛单源, 或 as const 替代。
// declare enum 两处声明 → 成员集不确定
坑 16 · lib.dom 全局 any 泄漏 — window/document 到处 any 化的起点. 正解: 自定义全局属性必须声明形状。
// (window as any).foo ✗ → declare global interface Window { foo?: Foo }
坑 17 · patch 类型升级丢失 — 手改 node_modules 的 d.ts, 重装即失效. 正解: pnpm patch / patch-package 纳管补丁。
pnpm patch @heavy/types   # 补丁进版本控制
坑 18 · declare global 忘了 — 直接 declare interface Window 在模块文件里无效. 正解: 包在 declare global {} 里。
// 模块文件内: declare global { interface Window {...} }
坑 19 · 资源声明太宽 — *.png 全导 string, 想要带尺寸导入时没模型. 正解: 按需声明多个通配形态。
// vite 官方 client.d.ts 已含常用资源, 优先 reference 它
坑 20 · 声明不测 — 发布的 d.ts 从未被消费方编译过. 正解: CI 里 pack+安装+tsc 三连门禁。
npx tsc --noEmit consumer.ts   # 干净目录跑