1. 认识声明文件它到底解决了什么问题1.1 从一段“能运行但没有提示”的旧代码说起我在项目里经常会遇到这种情况对接一个老模块函数能跑但编辑器里完全没有任何类型提示。你把鼠标悬停上去看到的只有一片红色的错误波浪线控制台里冒出Could not find a declaration file for module xxx。更头痛的是自己半年前写的一个纯 JS 工具函数今天打开项目已经想不起来参数到底该传什么了。很多刚接触 TypeScript 的人会抱怨这类型系统怎么这么烦明明运行得好好的非要在编译期卡我一下。但如果你真的在一个中大型项目里待过就会明白类型缺失的代价远不止“编辑器报几行红”那么简单。它意味着你不敢轻易重构意味着一个函数有七八种历史遗留的调用方式却没人知道哪些是合法的意味着新人接手代码时只能靠猜。声明文件.d.ts就是为这个问题而生的。它不是业务逻辑的实现而是纯粹的类型描述层描述一个 JS 文件对外暴露了哪些接口、函数、类它们的入参和返回值长什么样。你可以把它理解成一本“接口说明书”运行时不依赖它但开发者时刻需要它。.d.ts 文件的价值体现在三个层面第一给你自己的历史 JS 代码补上类型让老项目能渐进式接入 TypeScript第二给第三方 JS 库提供类型支持让你不再面对满屏的 any 和“无法找到声明文件”的红线第三给自己即将发布的 npm 包附带类型入口让下游用户获得完整体验。这篇文章我会从原理讲到实操再到发布流程和排查技巧尽量把声明文件这一整个链路讲透。1.2 .d.ts 背后的真实机制想看透声明文件得先理解 TypeScript 编译器的工作方式。TypeScript 源码.ts 文件经过编译后会输出纯 JavaScript.js 文件同时把所有类型信息全部抹掉。这也是为什么 .js 文件能被 Node.js 和浏览器直接执行——因为它们根本不理解类型也不需要理解类型。但类型信息在开发阶段是有价值的。假设编译一个src/utils.ts文件正常情况下只会生成dist/utils.js。可如果你在tsconfig.json里开启了declaration: true这个选项编译器会额外生成一个dist/utils.d.ts。这个文件对应的是同一个模块的类型描述里面没有函数实现只有declare开头的声明语句。比如源码写了export function formatName(first: string, last: string): string { return ${first} ${last}; }编译生成的utils.d.ts就是export declare function formatName(first: string, last: string): string;看到关键区别没有declare关键字的意思是我告诉 TypeScript 这个东西存在但你不需要检查它的具体实现也不用为它生成任何运行时代码。声明文件里的每个实体本质上都是编译器眼中的“类型坐标”它们不产生 JS 输出。所以一个 .d.ts 文件本身不会产生任何运行时开销它只在编译和编辑器的静态分析阶段起作用。这一点非常重要很多初学者会误以为 .d.ts 会影响打包体积或运行性能实际上完全不用有这个顾虑。另外要注意TypeScript 里有一个“声明文件即环境”的思想。编译出的 .d.ts 描述的是模块内部的结构而你自己写的 .d.ts 还可以描述那些“现实中存在、但无法用 TypeScript 直接描述”的东西——比如一个全局变量window.__myConfig__、一个不遵循模块规范的旧脚本、一种特定的模块格式。这些场景只靠自动生成是做不到的必须手写声明文件。2. 从零开始编写你的第一个声明文件2.1 准备阶段搭建最小验证环境很多教程一上来就铺开讲各种语法结果读者看完一脸懵这东西到底放在哪里怎么验证我写的对不对我更推荐的做法是先搭一个能即时反馈的最小环境一边写一边验证。新建一个工作目录在里面执行npm init -y npm install --save-dev typescript然后在tsconfig.json里写入{ compilerOptions: { target: ES2020, module: CommonJS, declaration: true, strict: true, outDir: dist, rootDir: src }, include: [src] }注意declaration: true这一项它控制编译时是否生成 .d.ts 文件。我建议任何时候都打开这个开关成本极低收益却很大每写一个 .ts 文件都会自动得到一个对应的 .d.ts。这能帮你直观感受“类型描述层”和“实现层”的对应关系。再装一个ts-node或直接使用tsc命令做验证。我习惯用tsc --noEmit切换的检查模式它只做类型校验不输出文件反馈速度很快。2.2 核心语法逐个拆解声明文件里的主角是declare关键字它后面可以跟各种结构。我按实际使用频率从高到低排一下顶层变量声明declare const version: string; declare let count: number;这里用const和let都没问题对应的是这个变量的可变更性。在全局声明中declare let声明的东西可以被重新赋值declare const不行。函数声明declare function parseHtml(html: string): Document;函数声明适合描述一个直接挂载在全局或模块下的函数。如果需要函数有多种调用形式可以写多个同名的declare function这就构成了重载。类声明declare class EventBus { on(event: string, handler: (...args: unknown[]) void): this; once(event: string, handler: (...args: unknown[]) void): this; off(event: string, handler: (...args: unknown[]) void): this; emit(event: string, ...payload: unknown[]): boolean; }类声明里只用写成员的类型签名不用写方法的实现体。注意构造函数的写法它没有名字直接用constructor关键字描述参数。如果一个类不能被直接实例化可以把构造函数标记为protected或private这是一个容易忽略但很实用的细节。接口和类型别名interface UserProfile { id: string; name: string; email?: string; roles: string[]; } type Callback (err: Error | null, data?: UserProfile) void;在声明文件里接口interface和类型别名type本身就有类型意义它们不需要加declare前缀。这一点容易混但规则其实很简单凡是会产生运行时结构的变量、函数、类、枚举、模块都需要declare或等价的关键字来描述凡是纯类型层面的接口、类型别名本身就只存在于类型空间直接写就行。命名空间declare namespace UI { interface Theme { primaryColor: string; } function render(theme: Theme): void; }命名空间非常适合描述一个“带有内部结构的全局对象”。在老式库的声明文件里namespace用得非常多。它与 ES Module 的“模块内导出”形成互补模块导出是文件级的命名空间更像是在类型层面组织一组相关声明。模块声明模块声明是 99% 的情境下你真正要用到的东西。因为你从 npm 装下来的第三方包本质上就是一个模块只不过它没有类型描述或者类型描述不全declare module legacy-utils { export function translate(input: string, targetLang: string): Promisestring; export const supportedLangs: string[]; export interface TranslateOptions { timeout: number; retries: number; } }写在一个declare module xxx块里的内容等价于描述“名为 xxx 的模块对外导出了什么”。这个语法经常被误认为只能在 .d.ts 文件里用其实只要你写的是全局脚本文件没有顶层 import/export 的 .ts 文件在普通的 .ts 文件里也可以用declare module给一个不存在类型的第三方模块补声明。2.3 实战为一个本地 JS 模块补类型理论看一百遍不如动手写一遍。我设计一个非常典型的场景项目里有一个src/legacy/calculator.js文件是整个项目的基础工具模块但它是四年前用原生 JS 写的没有任何类型。现在项目要全面 TypeScript 化第一步就是给它补一个完整的声明文件。先看一下模块的源码假设它是 CommonJS 风格用 Node 开发的老模块内部大概是这样的// src/legacy/calculator.js const cache new Map(); function add(a, b) { if (typeof a string || typeof b string) { return String(Number(a) Number(b)); } return a b; } function multiply(a, b, precision) { const factor precision ? Math.pow(10, precision) : 1; return Math.round(a * b * factor) / factor; } function withCache(fn) { return function(...args) { const key args.join(,); if (cache.has(key)) return cache.get(key); const result fn(...args); cache.set(key, result); return result; }; } module.exports { add, multiply, withCache, cache };我要在同一个目录下创建calculator.d.ts。但这里有个关键抉择这个模块是 CommonJS 的导出方式是module.exports { ... }那么声明文件应该怎么写第一种方式用export 语法// src/legacy/calculator.d.ts declare interface AddResult { value: number | string; } declare function add(a: number, b: number): number; declare function add(a: string, b: string): string; declare function add(a: string | number, b: string | number): string | number; declare function multiply(a: number, b: number, precision?: number): number; declare function withCacheT extends (...args: any[]) any( fn: T ): (...args: ParametersT) ReturnTypeT; declare namespace calculator { export { add, multiply, withCache }; export const cache: Mapstring, unknown; } export calculator;第二种方式用 ES Module 风格导出前提是tsconfig里启用了esModuleInterop或者你需要兼容现代的 import 写法// src/legacy/calculator.d.ts export function add(a: number, b: number): number; export function add(a: string, b: string): string; export function multiply(a: number, b: number, precision?: number): number; export function withCacheT extends (...args: any[]) any( fn: T ): (...args: ParametersT) ReturnTypeT; export const cache: Mapstring, unknown;实际操作中我更推荐第二种——ES Module 风格。原因有三个一是现代项目里import { add } from ./calculator的写法已经是主流esModuleInterop也基本默认开启二是这种写法语义直观工具链支持最完善三是自动生成的 .d.ts 文件统一采用这种风格你手写时保持一致后续维护成本最低。但要注意一个坑ES Module 风格的声明文件在描述一个真实存在的 CommonJS 模块时完全没问题但如果你在strict模式下被一个“模块缺失”的报错困扰大概率是被allowJs或esModuleInterop的某些组合搞晕了。这类问题后面我会专门讲排查思路。2.4 自动生成与手动编写的取舍很多新手会纠结我要手动写还是让编译器自动生成答案是两者不是非此即彼的替代关系。自动生成declaration: true适用于你有 .ts 源码想让编译产物自带类型。这是 TypeScript 项目发布 npm 包的标准做法生成的 .d.ts 与实现保持绝对同步不会出现“代码改了但声明忘了更新”的问题。手动编写适用于四类场景没有类型信息的第三方 JS 库需要外部补充声明。老项目里的历史 JS 模块你暂时不想把它改写成 .ts但想给它类型提示。需要描述全局变量、特殊模块格式、非 JavaScript 资源图片、CSS 文件等。你希望对外暴露的类型和内部实现解耦隐藏一些不希望的细节。举个典型例子一个项目里要引入一个纯脚本 CDN它在 window 上挂了一个全局对象window.sdk。自动生成怎么都不可能凭空造出这个类型就得手动写// src/global.d.ts declare global { interface Window { sdk: { init(config: { appId: string; env?: dev | prod }): void; on(event: ready | error, cb: (e?: Error) void): void; }; } } export {};注意这里export {}的作用它告诉 TypeScript 这个文件是一个模块不是全局脚本。既然变成了模块就不能直接往全局类型里塞东西所以要借助declare global块来声明全局扩展。关于“自动生成 手动补充”的组合拳我的实践是源码是 .ts 的用declaration: true自动生成同时维护一个types/目录专门放手写的补充声明包括declare module补丁、全局声明等并在 tsconfig 的include里把它加进去。这个目录里的文件命名可以带上-patch.d.ts之类的后缀方便一眼识别哪些是手写的。3. 声明文件的工程化组织、校验与发布3.1 项目的类型入口配置如果你的项目是给外部使用的 npm 包类型入口配置是重中之重。这里有两个关键字段需要在package.json里配置{ name: my-utils, types: dist/index.d.ts, main: dist/index.js, typesVersions: { 4.5: { dist/*: [dist/ts3.9/*] } } }types字段指向类型入口文件它等价于过去约定俗成的typings字段。如果没写这个字段TypeScript 默认会找index.d.ts如果连这个都找不到就会去查找同目录下的index.ts。typesVersions的意义要复杂一些它用于适配不同版本的 TypeScript 对同一包使用不同的类型定义。比如你的库用了比较新的语法const类型参数老版本 TS 看不懂所以给它们提供一份旧语法版本的类型。这个字段不太常用但如果你维护的包用户基数大迟早要碰到。在 TypeScript 5.0 之后还引入了一个bundler模块解析模式它会影响 .d.ts 文件最终的引用方式。如果你搞不清楚 package.json 里的 exports 字段对 types 的影响最稳妥的方案是保持 exports 指向显式声明的 dist 文件而不是让 TypeScript 自己去猜{ exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.js, require: ./dist/index.js } } }注意 exports 里的types条件必须放最前面否则 TypeScript 会因为遇到 resolveimport条件而错误加载 .js 文件或直接找不到类型。3.2 利用工具验证类型正确性类型声明写完了不代表就正确了。你需要一套验证手段。最直接的方式是写类型测试文件用编译器的检查能力来验证你的类型定义是否覆盖了所有预期场景。我常用的做法是在包里建一个test-d目录里面放类型测试// test-d/calculator.test-d.ts import { expectType } from tsd; import { add, multiply, withCache } from ../src/legacy/calculator; // 验证加法的数字重载 expectTypenumber(add(1, 2)); // 验证字符串重载返回字符串 expectTypestring(add(1, 2)); // 验证可选参数不传时不报错 expectTypenumber(multiply(3, 4)); // 验证泛型包装函数的类型透传 const cachedAdd withCache((a: number, b: number) a b); expectTypenumber(cachedAdd(1, 2));配合tsd这类工具它能自动把类型断言和“这意味着运行时也是对的”区分开来。为什么这个很重要因为类型声明本质上是最容易出错却最不容易被发现的代码之一。运行时你出了错测试一跑就暴露类型声明错了往往要等下游用户开始使用时才反馈那时的排查成本就高多了。我当时踩过一个很典型的坑声明文件里给一个函数加了重载签名是接收number或string但第三个重载接受混合类型被写成了一个不存在的用法。编辑器不报错因为公开重载是声明文件里定义的代码里没人触发这个重载。直到一个下游用户试图把number数组传进去才发现重载签名之间有严重冲突。所以不要轻易省掉类型测试这一步。它不只是给“简单场景”兜底更重要的是能帮你构建一套“契约感”让你知道自己声明的类型是否真的变得可预期、可依赖。3.3 发布带类型 npm 包的流程发布一个带类型的包整体流程并不复杂但有几个细节会影响下游体验。先要确认构建脚本。如果你的包是从 TypeScript 源码构建的典型的构建命令是tsc --declaration --outDir dist这样做会一次性产出dist/index.jsdist/index.d.ts 可能的分包 .d.ts。注意declaration是tsc编译的一个输出产物选项实际上它依赖outDir的设置输出路径必须在同一目录内。很多项目为了控制产物结构会用ts-node或者打包器先转译再拷贝但最基础的做法就是 tsconfig 里的declaration: true这个配置会在编译时自动生成所有对应 .d.ts。发布前建议用npm pack --dry-run检查包内容重点看两个东西.d.ts文件是否被打进包里位置是否和types字段指向一致。是否把不需要的.ts源码、测试文件、内部工具脚本等误发到了 npm很多包发出去之后才被用户反馈“类型定义引用了不存在的文件”核心原因就是漏配了files白名单。推荐在package.json里设置{ files: [ dist, src/**/*.ts, types/**/*.d.ts ] }这个白名单最好同时包含编译产物和类型声明文件但不要滥用。如果你担心源码被公开会把“src 下的 .ts 文件全部发布”当作一种防御手段其实这只会让包体积变大还增加了别人逆向分析的成本。安全的做法是只发dist和types再通过tsconfig的include和exclude控制参与编译的文件范围。关于发布版本号与 TypeScript 版本的兼容性还有一个实操技巧在package.json里加一个peerDependencies提示下游用户建议的 TypeScript 最低版本。{ peerDependencies: { typescript: 4.5 } }但请注意别把 TypeScript 写进dependencies因为 TypeScript 是开发期工具不该作为运行时依赖否则会拖垮安装体验甚至引发版本冲突。3.4 关于 types 组织和 DefinitelyTyped 的协作方式如果你的项目不是你自己的 npm 包而是想给一个第三方 JS 库补类型那么有两种分发方式可选第一种是让库的原作者把类型内置到包里第二种是通过types/xxx独立发布到 DefinitelyTyped 仓库。这是目前社区里最常见的疑问。我个人的建议是如果你是库作者优先把类型内置到包里如果你只是使用者并且库的作者暂时不打算维护类型可以考虑为它提交 DefinitelyTyped但这需要遵守那套流程和许可协议。关于“内置类型”和“types”选择业界有一些不成文的默契。内置类型的好处是类型和实现绑定在一起版本同步的问题天然解决坏处是如果你没有精力维护好类型定义反而可能给下游用户带来 bug。types 独立发布的好处是类型维护者和库维护者的职责分离坏处是版本滞后严重库里加了一个新 APItypes 可能要几周后才补上。有不少知名库最终选择了第二种分发方式理由并非“不愿意维护类型”而是“不想承担类型版本的同步压力”。所以你会发现一些很核心的库你npm install后仍然需要再npm install types/xxx。这就是 TS 生态里很普遍的现象不必觉得奇怪。如果你要为一个老库补声明恰当的做法是先看库的内部是否存在types字段以及是否有容器自带的 .d.ts。没有的话再考虑新建src/types/目录用declare module 库名的方式做引用覆盖做完之后把声明文件加进 tsconfig 的 include 列表。4. 声明文件实战中的高危地带与排查手册4.1 最常见的三个报错长什么样把声明文件相关的报错归纳一下实战中 90% 以上的问题都逃不出这三类。第一类找不到声明文件。报错长这样Cannot find module legacy-utils or its corresponding type declarations.原因没有安装类型包或模块本身没有内置类型。排查步骤先看node_modules/legacy-utils/package.json是否有types/typings字段再看项目里有没有types/legacy-utils最后一步才是自己手写声明文件兜底。第二类全局变量在自己的代码里报错。报错长这样Cannot find name myGlobalVar.原因你可能在一个模块文件有 import/export 的文件里想直接访问一个全局变量而 TypeScript 的模块作用域里根本没有这个全局声明。要修正它不要简单地declare var myGlobalVar写到某个模块文件里因为那只会变成“局部声明”。正确做法是把全局声明放进一个没有 import/export 的.d.ts文件或使用declare global块。第三类模块声明内部的类型不统一。报错通常很隐蔽比如你写了declare module my-lib { export function doSomething(x: string): number; }但在业务代码里调用doSomething(123)TypeScript 居然没有报错运行时却炸了。这个问题的根源是declare module里的声明直接覆盖了 package 自带的类型如果两者不一致TypeScript 会选择“信这个声明文件”因为它在编译时认为这是更权威的类型来源。当出现这种“类型提示和实际实现不符”的情况优先检查模块解析顺序看是不是paths或typeRoots配置把某个旧声明文件提前解析了。4.2 模块声明与全局声明的边界感这个边界感是 .d.ts 场景里最容易出事故的地方我专门拿出来讲。TypeScript 里的文件分两种脚本文件没有 import/export属于全局作用域和模块文件有 import/export属于模块作用域。声明文件同样遵循这个规则。比如你的项目里有一个types/global.d.ts内容如下declare namespace Utils { function uuid(): string; }只要这个文件在 include 范围内就无需任何 import项目中任意文件都能用Utils.uuid()。因为它是脚本类型的声明文件声明直接进全局空间。但如果你在同一个文件里写了import { Request } from express; declare namespace Utils { function uuid(): string; }文件就变成了模块文件Utils不再是全局命名空间而是这个模块的内部导出。其他文件不 import 它就访问不到。这个细节坑了很多人尤其是那些习惯“所有声明都放一个 global.d.ts”的开发者。你在文件顶部加了一个 import 以后这个文件中的所有declare都失去了全局效果。要恢复全局效果必须显式用declare global包裹。我之前维护过一个大型老项目它的公共声明文件就是这种“被 import 污染”的状态。当时每次给新模块补类型都会出现“在某些文件里可用、在另一些文件里不可用”的诡异问题。后来梳理清楚声明文件的“脚本/模块”性质后把所有需要全局暴露的类型迁到了独立无 import 的文件里才彻底解决。这个坑一旦踩过终身难忘。4.3 类型细节的坑声明合并、重载与泛型的陷阱声明合并是一个既令人惊喜又令人头疼的特性。同一个命名空间或接口在多个声明文件里可以合并合并出来的interface会把所有成员叠加到同一个类型下。这个机制对做第三方类型扩展很有用但也极容易出错。举个例子。你写了一个声明文件declare interface Window { __APP_VERSION__: string; }能正常合并进全局 Window。但是如果另外一个库的声明文件也定义了一个__APP_VERSION__且类型是number就会出现全局接口成员冲突而报错往往非常隐晦。这种问题通常只能通过逐一排查引入的模块类型声明来找根源没有太多捷径。函数重载在声明文件里是高频写法但重载的顺序有讲究必须把最精确/最特殊的签名放在最前面最宽泛的放在最后。这个顺序不是可选的风格问题它是 TypeScript 解析重载时的实际规则。编译器会按出现顺序尝试匹配第一个匹配成功的签名会胜出。比如这样写就有问题declare function parse(input: string | number): number; declare function parse(input: string): string;因为第一条已经把字符串给匹配走了第二条永远不会生效。正确写法是把单类型版本放在前面declare function parse(input: string): string; declare function parse(input: number): number; declare function parse(input: string | number): string | number;泛型默认值也是容易忽略的点。在声明文件里如果你给泛型加了默认值但某处引用的类型没有正确传递TypeScript 会安静地使用默认类型而不是报错。默认值能提升灵活性但也增加了类型被“悄悄放宽”的风险。我建议在对外 API 中尽量让泛型由ParametersT或ReturnTypeT推导而不是依赖下游显式传泛型参数。最后提一下never类型。你不该手动把某个返回值标记为never除非它真的可能“永远不返回”。有些手写声明文件里为了省事把异常分支全部标成never导致下游调用方在使用if/else做类型收窄时出现完全不可达的分支反而让类型变得不可信。4.4 排查技巧三招定位类型问题如果只是学会了上面这些理论遇到实际问题时你可能还是手忙脚乱。分享我在实际项目里最常用的三个排查招数。第一招查类型入口和解析顺序。在项目根目录执行npx tsc --traceResolution --noEmit这个命令会打印 TypeScript 解析每个文件时尝试过的路径和顺序。当报错是“找不到声明文件”时它能直接告诉你 TypeScript 去了哪些目录找、找到了什么。非常救命。第二招用临时测试文件固化问题。与其在项目大代码里反复猜测不如写一个最小复现文件比如debug.tsimport { add } from ./legacy/calculator; const result add(1, 2);然后只编译这个文件看错误是否复现。这样能把“是环境问题”和“是声明问题”分开。这一步在大型项目里尤其有效因为大型项目的模块解析路径复杂某个 types 的版本错位可能会引起连锁报错。第三招记下.d.ts文件中的tsc --noEmit信息。只运行类型检查不输出产物避免被“是否生成了文件”干扰。如果声明文件本身引用了另一个不存在的类型这个命令会直接把那次引用暴露出来。顺着这个线索你基本都能找到问题节点。结尾一点真实的心得写声明文件这件事表面上看是在“补类型”本质上是在“定义接口契约”。它逼迫你去认真思考这个函数到底接受什么、返回什么这个模块对外承诺了哪些稳定能力哪些实现细节是不该暴露的。我刚上手的时候觉得这是件麻烦事现在反而觉得一个项目的类型边界清爽往往意味着它的架构边界也清晰。如果你只是简单用几下 TypeScript.d.ts 确实可以不用那么上心。可一旦你开始做底层库、维护共享模块或者要把老项目迁到 TypeScript声明文件的质量会直接决定整个过程的顺畅程度。我的最后一条建议是能自动生成的就自动生成必须手写的地方别偷懒每写完一个声明文件花十分钟写几行类型测试。这几个看似不起眼的习惯能在后续一年里给你省下大量不必要的排查时间。