老周是公司里最会写 JavaScript 的高手,他维护的 ajax-tools 库比祖传老宅还值钱:功能齐全,但墙上没有一张电路图。新来的小陈想给老宅加装一盏智能灯,对着满墙电线一脸茫然——他真正需要的,是一张标注着“这根是火线、那根是地线”的示意图。TypeScript 的声明文件(.d.ts)就是给这类“JS 老宅”绘制的电路图。它不改变老宅的一砖一瓦,只把每个接口、每个参数、每个返回值清清楚楚标在图纸上,让 TypeScript 在开工前就能发现接错线的地方。
这份指南会带你从“看不懂图纸”到“能自己画图纸”:认识 .d.ts 的基本结构、全局声明与模块声明、接口与类型玩具、DefinitelyTyped 社区类型库,最后学会为自己的 JS 库编写并发布声明文件。学完以后,你既能安全地使用老库,也能让后来者像小陈一样,拿到图纸就能放心动工。
对 AI 从业者来说,掌握声明文件能让你在接入、迁移、封装各类 JS/AI 工具库时获得完整的类型保护,显著减少联调期错误,这是从“能跑”走向“工程化交付”的关键能力。
可视化演示
交互展示TypeScript声明文件.d.ts的结构语法:模块声明、全局声明和类型定义方式。
本节配有交互动效(进入视口后自动加载,约 3KB)。
文件结构:声明文件组成 declare关键字声明类型不生成代码 为JavaScript库补充类型描述信息 .d.ts = Type Definitions 让JS库在TypeScript中安全使用
声明文件基础
老宅的电路图不会画桌椅板凳,也不会画墙漆颜色,它只关心电从哪里来、开关接到哪里、线径承受多大电流。声明文件也是这样:.d.ts 文件里没有函数体、没有具体逻辑,只有一份“这里有什么接口、参数是什么、返回什么”的类型图纸。TypeScript 编译器在编译前打开这份图纸,就能知道一个 JS 库的每个角落该怎么接线,而不用去翻那堆被压缩、被混淆的源码。
为什么需要专门一张图纸,而不是直接在源码里写注释?因为很多老库是用纯 JavaScript 写的,改造源码成本高、风险大。声明文件的妙处在于它独立于源码:你可以在不触碰老宅墙体的前提下,把图纸画在另一张纸上,TS 会照着图纸检查,最终编译产出时又会把图纸整个扔掉,不影响运行时的一砖一瓦。
从数学视角看,一个 JS 库对外暴露的接口,可以看作“导出名字到类型”的对应关系:
对小陈来说,拿到这张图纸后,他不用再拿电笔逐个戳墙洞试探;对 TypeScript 来说,声明文件让它从“猜谜模式”进入“看图施工模式”。老宅还是那座老宅,但再也不会因为接错线而让整层楼跳闸。
- API_JS代表 JS 库对外暴露的全部接口,包括函数、变量、类、枚举等。
- e 与 exports(L)e 是从 L 中导出的某个名字,它可能是一个
get函数,也可能是一个version常量。 - T_e给 e 标注的类型,比如
(url: string) => Promise<Response>。 - 整体集合声明文件就是把这些
(e, T_e)配对关系完整登记下来,供编译器查阅。
全局声明语法
老宅里有些东西是“祖传公共设施”:比如每间房都能直接看到客厅的总闸,不需要先从一个模块里 import 进来。在 JS 里,这类挂在 window 或全局作用域下的库,就需要全局声明。它的语法核心是 declare 关键字——在图纸上写下“这里有一个总闸”,但不负责真的把闸装上去。
在 .d.ts 里,declare var 声明一个全局变量,declare function 声明一个全局函数,declare class 声明一个全局类,declare namespace 则像一个收纳盒,把一组相关的全局变量、函数都装进去。它们都只有类型签名,没有实现体。对编译器来说,declare 是一句承诺:“这个符号确实存在于运行环境中,请按此类型使用。”
用集合的语言描述,全局声明就是在全局命名空间中登记一个新符号:
老宅的总闸不是小陈画出来的,它本来就在那里;declare 只是把它的位置和规格告诉每一个后来施工的人。全局声明的价值,就是让那些“到处都是、随手可用”的 JS 全局变量,在 TypeScript 项目里也拥有姓名和类型。
x 被 declare,它在全局集合中存在,但不会被编译成任何 JavaScript 输出。这就是“只画图纸,不砌墙”。- declare 关键字向 TypeScript 声明某物的存在,但不提供实现。
- xT:被声明的名字 x 以及它的类型 T,比如
apiVersion: string。 - x ∈ Globalsx 不来自某个具体模块,而是直接存在于全局作用域。
- x ∉ JS Output声明不会生成对应的运行时代码。
模块声明语法
老宅之外还有一片新区,每栋房子都有门牌和单元号。当一个 JS 库通过 import 被引用时,它就像一个模块化的“单元房”,需要一张标好门牌号的图纸,告诉 TypeScript 这个包导出什么、忽略什么。声明文件里用 export 描述模块的对外导出,用 import 在类型世界中进行引用。
模块声明最常见的形态是给第三方包写“外部模块声明”:在 .d.ts 中写 declare module "some-lib" { ... },把 some-lib 的导出项全部登记进去。如果项目里还有很多特殊后缀文件,比如 *.vue,也可以用通配符声明 declare module "*.vue",相当于给一整类文件提前贴上通用门牌。
一个模块的声明,本质上是模块名到导出集合的映射:
小陈给新区施工时,只要先看一眼单元房的“户型图”,就知道卧室在哪、厨房在哪。模块声明提供的正是这种确定性:import 进来的每个名字,都能在图上一眼找到对应位置,再也不会“明明文档里写了,编辑器却一直报错”。
import ... from 'm' 时,会去查这张表。- Γ(m)读作“模块 m 的类型环境”,也就是这个模块的声明图纸。
- e ∈ exports(m)e 是模块 m 真正对外导出的某个成员。
- eT_e:每个导出成员都配上了自己的类型签名。
- 整体映射从模块到导出集合的对应,构成了完整的模块类型图。
接口与类型声明
老宅装修时,插座面板是经常要换的部件:同一个面板形状,可以配两孔、三孔、USB 插口。interface 就像这样一个“面板模具”,它规定了一个对象必须有哪些字段、字段类型是什么;type 则像一个“缩略词”,把一段复杂的结构或联合类型起一个好记的名字。
接口还支持泛型,泛型就像给面板换上不同规格的插孔:Box<number> 装数字,Box<string> 装字符串。用泛型写一次,就能复用在多种数据类型上,减少了重复画图纸的时间。联合类型 A | B 表示“这个位置既可以接 A 也可以接 B”,交叉类型 A & B 表示“必须同时满足 A 和 B”。
从数学角度看,一个带泛型的接口就像一个参数化结构:
小陈在图纸上看到 Box<number>,就像看到“这个面板是两孔插座”;看到 Box<string>,则像是“这个面板是 Type-C 口”。接口和类型让复杂的图纸有了可复用的积木块,画起来省力,看起来也清晰。
Box 这个结构有一个类型参数 T,value 的类型由 T 决定,tag 固定是字符串。传入不同的 T,就得到不同的具体类型。- interface Box\<T\>声明一个名为
Box的接口,尖括号里的 T 是类型参数。 - valueT:字段
value的类型是参数 T,所以它可以是 number、string 或任何类型。 - tagstring:字段
tag被固定为字符串类型,不随泛型变化。 - 整体结构接口的实例必须同时包含
value和tag两个字段。
DefinitelyTyped
老宅所在的社区有一个公共档案馆,叫 DefinitelyTyped。它收录了成千上万个流行 JS 库的声明文件,每个库对应一个 @types/包名 的 npm 包。比如 lodash 没有自带类型,社区就在 DefinitelyTyped 里维护了 @types/lodash;你只需要安装它,TS 就能立刻读懂 lodash。
安装方式很简单:npm install -D @types/lodash。因为 @types 只在开发阶段帮助编译器工作,运行时并不需要,所以通常放在 devDependencies(开发依赖)里,就像图纸只给施工队看,不塞进房子交付清单里。
公共档案馆的匹配规则可以形式化为:
小陈不用自己画所有图纸,公共档案馆已经保存了大部分成熟库的版本。他只需要按照 @types 命名规则去查、去装,就能让老宅项目立刻获得社区多年积累的类型经验。
@types/L。这是 TypeScript 查找类型的默认逻辑。- getTypes(L)给定库 L 时,TypeScript 最终采用的类型来源。
- @types/LDefinitelyTyped 中为库 L 维护的声明包名。
- L 自带类型库自带类型时,优先读取它自己在 package.json 里指定的
types字段。 - 分支选择按“是否自带类型”分成两种情况,对应两种来源。
编写声明文件
当公共档案馆也查不到某个内部老库时,小陈就得自己动手画图纸。第一步不是埋头写代码,而是“勘察现场”:分析这个 JS 库对外导出了哪些函数、类、变量,支持哪些参数,返回值长什么样。把这些接口面梳理成清单,声明文件才不会漏画关键线路。
画图时要多设计公共类型。如果某个函数参数是一个对象,不要直接写一团长长的匿名类型,而是先 interface 或 type 给它命名;这样项目其他代码也能复用,后续维护只改一个地方。写完之后还要“模拟施工”:用一个真实的 TS 文件去调用这些 API,或者用类型测试工具验证,确保图纸和实际房子对得上。
一段类型声明可以看作对源码 API 面的签名建模:
自己画图纸时,最忌讳“想当然”。小陈每画一条线,都要回到老宅实际确认一下线路走向。好的声明文件不是越厚越好,而是每一个导出项都有对应、每一个类型都尽可能具体,不多不少刚刚好。
- Surface(L)库 L 对外暴露的完整类型表面。
- union 符号把所有签名合并在一起,避免遗漏。
- exports(L)库 L 的全部导出成员集合。
- signature(e)导出成员 e 的函数签名或属性类型。
发布与使用
图纸画完不能锁在抽屉里,得贴到房子门口,让每个需要的人都能看到。发布 npm 包时,声明文件要跟随包一起发布,并在 package.json 里告诉 TypeScript“入口图纸在哪里”——这就是 types 字段。它通常指向 index.d.ts 或构建产物里的类型入口文件。
同时,files 字段决定哪些文件会被打包进 npm 发布物。为了让 .d.ts 真正发布上去,必须把它列入 files 白名单,否则即使写了 types 字段,用户安装后也找不到图纸。发布后,项目里直接 import 这个库,TypeScript 就会按照 types 字段找到声明文件,完成类型检查。
发布配置的核心约束可以写作:
老宅装修完工后,小陈在门口钉了一个“管线示意图”信箱。路过的人不用敲开他家的门,也不用拆墙,就能知道线路布局。发布声明文件做的正是这件事:让你的库在 TypeScript 世界里成为一位“有图纸、可放心施工”的邻居。
types 入口,还要保证入口文件确实在 files 发布列表中。两条同时满足,用户才能拿到图纸。- pkgFieldspackage.json 中与包发布相关的字段集合。
- main 字段JS 实现入口,决定运行时加载哪个文件。
- types 字段类型声明入口,决定编译时读取哪个 d.ts。
- types ∈ files类型文件必须出现在发布白名单中。
AI 应用场景
老项目渐进迁移
阶段:数据工程与前端基础设施改造
解决的问题:传统方式是一次性把老 JS 项目全部改写成 TS,周期长、风险高。用声明文件可以先给老模块补一张张“电路图”,让 TS 一边检查新代码,一边继续使用旧模块;每个模块验收后再逐步迁移,风险被拆小。
具体案例:一个维护了三年的可视化报表系统,内部有几十个全局工具函数。团队为这些函数编写 global.d.ts,让新开发的图表组件能安全调用,旧的 .js 代码则继续照常运行,最终将迁移周期从两个月缩短到两周。
接入社区 AI 工具库
阶段:模型服务调用与 SDK 集成
解决的问题:很多 AI/ML 相关 JavaScript SDK 早期没有类型,接入时经常拼错参数名或者把字符串传给数字字段,直到运行才报错。通过 @types 包或自定义声明文件,让所有参数在编辑器里自动补全和校验。
具体案例:某团队在项目中调用一个 LLM 对话 SDK,安装 @types/xxx 后,messages、maxTokens、temperature 等字段全部有了类型提示,联调时的参数错误率下降了约 70%。
发布开源组件库时补全类型
阶段:模型应用产品封装与对外发布
解决的问题:开源库如果只有 JS 没有声明文件,用户接入门槛极高,社区贡献者也难以维护。编写并发布声明文件,让组件库同时具备“可运行”和“可类型检查”两种能力。
具体案例:某团队开源了一个图表组件库,在发布前补充 index.d.ts,并在 package.json 中配置 types 字段和 files 白名单。发布后一周内,GitHub Issue 里关于“类型报错”的问题数量几乎清零。
Python 演示
# -*- coding: utf-8 -*-"""Generate a minimal .d.ts for a JSON-described API."""from typing import Any, Dict, List def to_ts_type(t: str, aliases: Dict[str, str]) -> str: aliases_map = aliases or {} return aliases_map.get(t, t) def render_params(params: List[Dict[str, str]], aliases: Dict[str, str]) -> str: parts = [] for p in params: name = p["name"] optional = "?" if p.get("optional") else "" ts_type = to_ts_type(p["type"], aliases) parts.append(f"{name}{optional}: {ts_type}") return ", ".join(parts) def render_function(name: str, decl: Dict[str, Any], aliases: Dict[str, str]) -> str: params = render_params(decl.get("params", []), aliases) ret = to_ts_type(decl.get("returns", "void"), aliases) return f"export function {name}({params}): {ret};" def render_class(name: str, decl: Dict[str, Any], aliases: Dict[str, str]) -> str: lines = [f"export class {name} {{"] for method in decl.get("methods", []): lines.append(" " + render_function(method["name"], method, aliases)[len("export "):]) for prop in decl.get("props", []): pname = prop["name"] ptype = to_ts_type(prop["type"], aliases) lines.append(f" {pname}: {ptype};") lines.append("}") return "\n".join(lines) def build_declaration(schema: Dict[str, Any]) -> str: aliases = schema.get("aliases", {}) out = ["// Generated by typemarker demo", "// 为无类型 JS 库生成声明文件骨架", ""] for name, decl in schema.get("functions", {}).items(): out.append(render_function(name, decl, aliases)) for name, decl in schema.get("classes", {}).items(): out.append(render_class(name, decl, aliases)) for name, type_expr in schema.get("types", {}).items(): out.append(f"export type {name} = {type_expr};") return "\n\n".join(out) + "\n" if __name__ == "__main__": schema = { "aliases": {"Response": "{ status: number, data: unknown }"}, "functions": { "get": { "params": [ {"name": "url", "type": "string"}, {"name": "timeout", "type": "number", "optional": True} ], "returns": "Promise<Response>" } }, "classes": { "EventBus": { "methods": [ {"name": "on", "params": [{"name": "event", "type": "string"}], "returns": "this"}, {"name": "emit", "params": [{"name": "event", "type": "string"}], "returns": "void"} ], "props": [{"name": "version", "type": "string"}] } }, "types": { "User": "{ id: number, name: string }" } } print(build_declaration(schema))
def build_declaration(schema)把 JSON 格式的 API 描述翻译成 d.ts 文本。aliases_map.get(t, t)支持把简写类型名替换成更完整的 TS 类型表达式。parts.append(f"{name}{optional}: {ts_type}")拼出“参数名加问号加冒号加类型”的 TS 参数片段。render_function(name, decl, aliases)为函数生成export function ...;签名。render_class(name, decl, aliases)为类生成包含方法和属性的声明骨架。out.append(f"export type {name} = {type_expr};")抽出公共类型,生成type别名。schema = {...}模拟一个没有类型的 JS 库的 API 清单。print(build_declaration(schema))在终端输出完整的声明文件初稿。
常见误区
误区我以为 declaration 文件就像一个附加模块,会被编译进 JavaScript 包里,导致项目越来越大、运行变慢。
正解.d.ts 只存在于编译期。它像施工图纸,不会砌进墙里,也不会成为新装修的一部分。TypeScript 编译器使用它完成类型检查后,不会为它生成任何运行时代码,产物体积和运行时性能都不受影响。
为何常见:很多人看到 .d.ts 文件也会被编辑器加载、被 tsconfig 引用,就误以为它是运行时文件。实际上“类型代码只在编译期存在”是 TypeScript 最基本的心理模型。
误区我装了 `@types/xxx` 就万事大吉,即使库升到新版本,类型也自动跟着变。
正解@types 包由社区维护,有自己独立的版本号,通常和主库的版本号对应,但可能滞后。升级主库后要检查 @types 版本是否匹配;如果主库新增了 API,而过时的 @types 不知道,TS 仍可能报“类型不存在”。
为何常见:npm 安装规则让 @types 带上了主库的版本命名,人们容易以为 npm 会自动帮它们做双向同步。其实它是另一份独立的“图纸副本”。
误区我在声明文件中写了一个 `export function load()`,又想再写一个 `declare var version` 让 window 上也有这个变量,结果一直不生效。
正解只要 .d.ts 文件里有顶层 import 或 export,它就会被当作一个模块,而不是全局脚本。此时顶层 declare var 只属于该模块作用域,并不会挂到全局;要想影响 window,必须在模块内部写 declare global { ... } 块。
为何常见:大家默认“d.ts 都是全局的”,忘了模块语法会改变文件的“身份”。一个文件是模块还是脚本,取决于它有没有顶层 import/export。
误区我写 `declare function doThing(args: any): any;`,所有调用都不报错了,这就是合格的声明文件。
正解全面使用 any 等于把类型检查关掉,相当于图纸上所有线路都标着“随便接”,接错也不会有任何警告。真正有用的声明文件要尽量给出精确的结构:对象要有字段、函数要有参数类型和返回值类型。
为何常见:遇到复杂的第三方 API 时,写精确类型很费劲,写 any 最省事。但这种“省事”会让声明文件失去核心价值,项目里很快会到处是未知类型。
面试题
Q1. d.ts 文件里的类型信息在什么时候真正参与检查?
- A.编译期类型检查← 正确答案
- B.运行期函数调用
- C.浏览器解析页面
- D.打包压缩阶段
答案:A。声明文件像施工图纸,只在设计施工阶段(编译期)被检查使用;房子建成后图纸不会参与居住体验。运行期和浏览器加载时都不会读取 d.ts。
Q2. 库的声明文件入口通常配置在 package.json 的哪个字段?
- A.types 字段← 正确答案
- B.main 字段
- C.scripts 字段
- D.version 字段
答案:A。types 字段告诉 TypeScript 去哪里找声明文件;main 是运行时入口,与类型无关。
Q3. 一个 d.ts 文件以 import/export 开头,它会被 TypeScript 当作什么?
- A.全局声明文件
- B.模块声明文件← 正确答案
- C.配置文件
- D.运行时脚本文件
答案:B。有顶层 import/export 就说明它想做模块化交互,TS 会把它当成模块声明文件;只有没有任何顶层 import/export 时才会当作全局脚本。
Q4. 某个库 my-lib 没有自带类型,最常见的做法是?
- A.安装对应 @types 包← 正确答案
- B.删除所有 import 语句
- C.去掉 tsconfig.json
- D.把库改成 TS 源码
答案:A。没有自带类型时,先去 DefinitelyTyped 找 @types/my-lib;安装后编译器就能读到社区维护的类型图纸。删 import、关检查都是回避问题。
Q5. declare namespace App 在 d.ts 中最主要的作用是?
- A.声明全局命名空间← 正确答案
- B.导入外部 npm 包
- C.生成运行时对象
- D.定义默认导出
答案:A。declare namespace 把一组全局相关的类型和函数收进一个命名空间容器里,方便组织和引用,但不会生成任何运行时对象。
Q6. 模块 d.ts 想给 window 挂属性,必须写在?
- A.declare global 内← 正确答案
- B.export default 内
- C.顶层 export 外
- D.单独的 JS 文件
答案:A。模块中的顶层 declare 作用域只限当前模块;想去影响全局 window,就需要 declare global { ... } 块,像从单元房走到小区总配电房改造线路。
Q7. 发布 npm 包时,files 字段包含 .d.ts 的核心原因是?
- A.让 npm 发布类型文件← 正确答案
- B.减少包的运行体积
- C.防止源码被看到
- D.让 main 字段生效
答案:A。files 白名单决定哪些文件进入 npm 发布物。只有把 .d.ts 放进去,用户安装后才能真正拿到声明文件,否则 types 字段会指向一个不存在的文件。
Q8. 以下哪些是合法的 declare 声明形式?
- A.declare var← 正确答案
- B.declare module← 正确答案
- C.declare function← 正确答案
- D.declare import
答案:ABC。declare var、declare module、declare function 都是合法写法;declare import 不存在。import 本身就是引入类型,不需要再用 declare 修饰。
Q9. 关于 DefinitelyTyped 的 @types 包,正确说法有?
- A.由社区维护类型← 正确答案
- B.只包含类型声明← 正确答案
- C.包含库的实现
- D.版本通常跟随主库← 正确答案
答案:ABD。@types 包是 DefinitelyTyped 社区维护的“类型图纸包”,只含声明不含实现;版本通常与主库大版本对应,所以主库升级后也要检查 @types 版本。
Q10. d.ts 文件能带来哪些好处?
- A.编译期捕获错误← 正确答案
- B.获得编辑器提示← 正确答案
- C.让 JS 运行更快
- D.替代所有单元测试
答案:AB。声明文件让 TS 在编译期发现类型错误,也让编辑器提供自动补全和悬停提示;但它不会改变 JS 运行时性能,也不能替代单元测试,图纸再清晰也不能替工人验收。