TypeScript 声明文件 — .d.ts 的编写与发布,一次讲透
TypeScript 再强大,也管不到"别人写的 JS"。当项目依赖一个没有类型的旧 JS 库(或公司内部的遗留脚本),你的编辑器瞬间退化成记事本——全是 any,红线全灭。今天学的
.d.ts声明文件,就是你替外部 JS 世界写的"类型说明书":declare关键字家族、模块声明扩展(declare module)、全局类型(declare global)、三斜线指令,以及发布 npm 包时 types 字段的正确姿势。学完今天,"无类型依赖"不再是向 any 妥协的理由。
目录
- 一、声明文件是什么:类型的"外部接口"
- 二、.ts 与 .d.ts 的本质区别
- 三、declare 关键字全家福
- 四、给无类型的 JS 库补类型
- 五、declare global 与全局类型
- 六、三斜线指令
- 七、@types 生态:DefinitelyTyped
- 八、发布自己的包:types 配置全解
- 九、实战:给设备 SDK 补声明文件
- 十、常见坑点与最佳实践
- 十一、自测挑战
- 十二、总结与知识图谱
一、声明文件是什么:类型的"外部接口"
// 你天天在"消费"声明文件,只是没意识到:
// 这行为什么有类型提示?
import express from "express";
express().use("/api", handler);
// 因为 node_modules/@types/express/index.d.ts 里写着:
// declare function express(): Express;
// 这行为什么是 any?
import legacyPlc from "company-plc-sdk";
legacyPlc.readRegister("D100");
// ↑ any —— 因为这个包【没有】声明文件
// 任何拼写错误(readRegistor)都不会被发现
// .d.ts 就是这样的文件:只描述"形状",不含任何实现。
二、.ts 与 .d.ts 的本质区别
| 维度 | .ts | .d.ts |
|---|---|---|
| 内容 | 实现 + 类型 | 只有类型声明 |
| 产物 | 编译为 .js | 原样保留(不产出 js) |
| 能否出现逻辑 | 能 | ❌ 编译错误(只许 declare) |
| 角色 | 你的代码 | 你/别人的代码的说明书 |
// device.ts —— 实现
export class Device {
temp = 65;
read(): number { return this.temp; }
}
// device.d.ts —— 同一存在的"说明书"(tsc 自动生成)
export declare class Device {
temp: number;
read(): number;
}
// ↑ 注意:没有函数体!只有签名
三、declare 关键字全家福
declare 的语义只有一句话:“以下内容真实存在于别处(JS 世界),我只负责描述它”。
// ===== 1. declare const / let / var —— 声明全局变量 =====
declare const APP_VERSION: string;
declare let __DEV__: boolean;
// 常见来源:构建时注入的全局常量(vite define、webpack DefinePlugin)
// ===== 2. declare function —— 声明全局函数 =====
declare function ga(command: string, ...args: unknown[]): void;
// 描述 <script> 标签引入的第三方(如 Google Analytics)
// ===== 3. declare class —— 声明全局类 =====
declare class LegacyDevice {
constructor(port: string);
read(): number;
static calibrate(): void;
}
// ===== 4. declare namespace —— 声明全局命名空间(老式全局库) =====
declare namespace jQuery {
function ajax(settings: { url: string; method?: string }): void;
const fn: { tooltip: (options?: object) => void };
}
// ===== 5. declare module —— 声明一个模块(今天的主角,下节详讲) =====
declare module "company-plc-sdk" {
export function readRegister(addr: string): number;
}
// ===== 6. declare global —— 在模块内扩充全局(第五节详讲) =====
// ⚠ 所有 declare 语句【零运行时代码】——它们编译后全部消失
四、给无类型的 JS 库补类型
4.1 场景一:快速止血(一行 any 声明)
// types/shims.d.ts
declare module "company-plc-sdk";
// 效果:import 进来的是 any —— 至少编译不报错了
// 适用:依赖很多、先跑起来再说(技术债,记录在案)
4.2 场景二:认真补全(正式声明模块)
// types/company-plc-sdk.d.ts
declare module "company-plc-sdk" {
/** 连接配置 */
export interface PlcOptions {
host: string;
port?: number;
timeout?: number;
}
/** 设备寄存器地址(如 "D100") */
export type RegisterAddress = string;
export class PlcClient {
constructor(options: PlcOptions);
connect(): Promise<void>;
readRegister(addr: RegisterAddress): number;
writeRegister(addr: RegisterAddress, value: number): Promise<void>;
close(): void;
}
export const VERSION: string;
}
// 使用侧立刻获得完整类型:
import { PlcClient } from "company-plc-sdk";
const client = new PlcClient({ host: "192.168.1.10" });
await client.connect();
const temp = client.readRegister("D100");
// ↑ string 参数有提示,返回 number
4.3 场景三:写"影子文件"(同名 .d.ts 伴随本地 JS)
// 项目里的遗留文件(不许改):
// src/legacy/monitor.js
// module.exports.getCurrentTemp = function () { ... }
// module.exports.setAlarm = function (level, msg) { ... }
// 你在旁边写"影子声明":
// src/legacy/monitor.d.ts
declare const getCurrentTemp: () => number;
declare const setAlarm: (level: 1 | 2 | 3, msg: string) => void;
export { getCurrentTemp, setAlarm };
// 现在 TS 项目里 import "./legacy/monitor.js" 也有类型了
// 这就是"渐进迁移"的标准姿势:JS 不动,类型从旁边长出来
五、declare global 与全局类型
5.1 在模块文件里扩充全局
// 普通的模块文件(有 import/export 就是模块)
// src/types/window-extensions.ts
export {};
declare global {
// 给 window 挂载的调试工具补类型
interface Window {
__DEVICE_HUB__?: {
subscribe(topic: string, fn: (data: unknown) => void): void;
};
}
}
// 使用(任何文件,无需导入):
window.__DEVICE_HUB__?.subscribe("hub:device/x", (d) => console.log(d));
5.2 interface 合并的魔法
// 为什么上面那样写就能扩充 Window?
// TS 对【同名 interface 自动合并】(declaration merging):
interface Window { deviceHub: unknown } // 全局已有 100 个属性
interface Window { __DEVICE_HUB__?: object } // 你的声明合并进去
// 最终 Window = 两份声明的并集 —— 这就是"扩充全局"的原理
// 同一技巧也用于扩充第三方库的接口:
declare module "express-serve-static-core" {
interface Request {
userId?: string; // 中间件挂上去的用户信息
}
}
// 之后所有 req.userId 都有类型
5.3 三种全局类型的投放位置
// 1. 环境全局(整个项目生效,无需导入):
// global.d.ts —— 写 declare global / declare module / declare const
// tsconfig 的 include 必须覆盖到它
// 2. @types/xxx 自动全局(如 @types/node 的 process)
// 只要装了,所有文件的 process 都有类型
// 3. 模块内 global 扩充(上面 5.1 的做法)
// 适合"扩展跟着实现走"的场景
六、三斜线指令
/// <reference types="node" />
// 显式引入 @types/node 的全局类型(等价于 tsconfig 的 "types": ["node"])
/// <reference path="./device.d.ts" />
// 显式引入相对路径的声明文件(老式项目组织,现代用 import 取代)
/// <reference lib="es2020.promise" />
// 显式引入内置 lib(一般用 tsconfig 的 lib 替代)
// 现状评估:
// - 三斜线指令是【前 tsconfig 时代】的遗产
// - 现代项目 95% 的场景用 tsconfig(types/lib)和 import 取代
// - 剩下 5%:@types 包内部互相引用、无法改 tsconfig 的场景
// 结论:看得懂、读得通,但新代码不要主动写
七、@types 生态:DefinitelyTyped
# 大多数知名 JS 库的类型不在自己包里,而在 DefinitelyTyped 仓库:
npm i -D @types/lodash # lodash 的类型(三方维护)
npm i -D @types/node # Node API 的类型
# 判断依赖要不要装 @types:
# 1. 包自带类型(package.json 有 types/exports.types 字段)→ 不用装
# 2. 有对应的 @types/xxx → 装上
# 3. 都没有 → 今天学的 declare module 自己写
// tsconfig 里控制自动加载哪些 @types:
{
"compilerOptions": {
"types": ["node", "vite/client"]
// 不写 types → 自动加载 node_modules/@types 下【全部】
// 写了 → 只加载列出的(隔离全局污染,推荐大项目显式声明)
}
}
八、发布自己的包:types 配置全解
// ===== 你的 typed-utils 要发布,类型怎么带上有三条路 =====
// 路线 A:源码即类型(.d.ts 由 tsc 生成,最主流)
{
"name": "typed-utils",
"main": "./dist/index.js",
"types": "./dist/index.d.ts" // 指向 tsc --declaration 的产物
}
// 路线 B:exports 多条件(现代包,Day 23 讲过)
{
"exports": {
".": {
"types": "./dist/index.d.ts", // ⚠ 永远第一位
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
// 路线 C:publishConfig 里单独指向(monorepo 常用,了解即可)
{
"publishConfig": {
"types": "./dist/index.d.ts"
}
}
// 用户视角验证(装包后三种状态):
// ① types 正确 → import 有完整类型提示 ✅
// ② 没配 types 但 dist 里有 index.d.ts → TS 猜测同目录同名(能找到,但不保险)
// ③ 都没有 → "Could not find a declaration file for module" —— 你今天的知识能自救:
// 自己写 declare module,或者(更好的做法)给作者提 PR
九、实战:给设备 SDK 补声明文件
今天在 typed-utils 项目里的实操任务:
1. 新建 types/ 目录,加入 tsconfig 的 include
2. 造一个"无类型依赖":写一个 legacy-logger.js(CJS,module.exports = ...)
放在 src/legacy/ 下
3. 为它写影子声明 legacy-logger.d.ts(4.3 的姿势):
- log(level, message)
- createLogger(prefix) → 返回新的 logger
- LEVELS 常量(DEBUG/INFO/WARN/ERROR 联合)
4. 写 global.d.ts:
- declare global 扩充 Window(挂 __LOGGER__ 调试口)
- declare const APP_VERSION: string(模拟构建注入)
5. 验证:
- import legacy-logger → 类型提示出现
- 故意拼错 log 方法名 → 编译报错(类型守卫生效)
- 写进今日博客:《给遗留 JS 世界写说明书》
十、常见坑点与最佳实践
坑点 1:声明文件没被 include 覆盖
症状:declare module 写了,import 还是报"找不到声明"
排查:tsconfig 的 include 是否包含 types/ 目录
("include": ["src"] 时 types/ 里的 .d.ts 根本没参与编译)
坑点 2:在 .d.ts 里写实现
// xxx.d.ts
declare function parse(s: string): number {
return Number(s); // ❌ 报错:An implementation cannot be declared in ambient contexts
}
// .d.ts 是纯说明书 —— 实现写 .ts,说明书只写签名
坑点 3:declare module 的路径写错
// ❌ declare module "./legacy/monitor" —— 模块声明只匹配【包名/子路径】
// ✅ declare module "company-plc-sdk"
// 相对路径的本地文件用"影子文件"(同名 .d.ts,见 4.3),不用 declare module
坑点 4:全局声明滥用
// ❌ 把业务类型全塞进 declare global:
declare global {
interface Device { ... } // 全局 Device —— 与其他库的 Device 撞名时灾难
}
// ✅ 业务类型走模块导出(import 使用):
export interface Device { ... }
// 全局只留给"真的是全局的东西"(window 扩充、构建注入常量)
坑点 5:skipLibCheck 掩盖了自己声明的错误
skipLibCheck 会跳过所有 .d.ts 的检查 —— 包括你自己写的!
排查自己声明的类型问题时,临时关掉它,检查完再打开
十一、自测挑战
挑战 1(基础):三种补类型方案选型
场景 A:依赖 lodash-es(有 @types 吗?怎么查?)
场景 B:公司内部报表 SDK(纯 JS,无类型,常用)
场景 C:window 上挂了一个 MQTT 实例(脚本标签注入)
→ 分别该用:装 @types / declare module / declare global?
挑战 2(进阶):给 jQuery 风格全局库写声明
// 老项目里这样用(script 标签引入,全局 $):
// $("#device-list").on("click", ".row", handler);
// $.ajax({ url: "/api/devices", method: "GET" });
// $.deviceHub = { publish(topic) {...} }
// 要求:写 global.d.ts,让上面三行全部有类型(含你自己挂的 deviceHub)
挑战 3(实验):声明合并验证
// 1. 写一个模块:export interface Config { host: string }
// 2. 在另一个文件里 declare module 扩充它,加 port?: number
// 3. 验证:导入后的 Config 是否同时有 host 和 port?
// 4. 思考:这个特性在"给 express 的 Request 加 userId"时为什么关键?
挑战 4(论文级):给新人讲清三个概念
不看资料,向橡皮鸭讲清楚:
1. .d.ts 与 .ts 的区别(产物、内容、角色)
2. declare 的语义为什么是"存在于别处"
3. types 字段在 exports 里为什么必须排第一(联系 Day 23 的条件匹配顺序)
十二、总结与知识图谱
声明文件(Day 24)
│
├── 本质:类型的"外部接口"
│ ├── .ts = 实现 + 类型 → 编译出 .js
│ └── .d.ts = 纯类型说明书 → 编译后消失
│
├── declare 全家福
│ ├── const / let / var —— 全局变量(构建注入)
│ ├── function / class —— 全局函数与类
│ ├── namespace —— 老式全局命名空间
│ ├── module —— 模块声明(补类型主力)
│ └── global —— 模块内扩充全局(interface 合并原理)
│
├── 补类型三姿势
│ ├── 一行 any(快速止血)
│ ├── declare module 全量声明(正式方案)
│ └── 影子文件(同名 .d.ts 伴随本地 JS,渐进迁移)
│
├── @types 生态
│ ├── DefinitelyTyped 三方维护
│ └── tsconfig types 数组控制加载范围
│
└── 发布侧配置
├── types 字段 → dist/index.d.ts
├── exports.types 永远第一位
└── declaration: true(tsconfig.build,Day 22 埋的伏笔)
一句话总结:.d.ts 是 TypeScript 与 JS 世界的边界海关文件——凡是你管不到的代码(旧库、脚本注入、构建常量),都用 declare 家族给它发"类型护照";而发布自己包时,types 字段的正确配置,就是给你的用户免掉这一切麻烦。
明日预告:Day 25 装饰器与元数据——类/方法/属性/参数四类装饰器、装饰器工厂、reflect-metadata,实战日志/权限/缓存三个工业级装饰器(总计划第 19 周的必修内容)。