声明文件(declaration file) 用 .d.ts、.d.mts 或 .d.cts 描述已有 JavaScript 的公开类型,不提供运行时实现。
编译器会相信声明;即使函数不存在、导出方式不符或返回值写错,声明本身仍可能通过类型检查。
让声明逐项映射真实导出,优先从类型化源码生成,并用正向与负向类型测试锁定公开契约。
是什么,为什么存在
声明文件是 JavaScript API 的静态外形。它记录模块导出的函数、类、常量和类型,或者记录宿主环境提供的全局名称。TypeScript 检查使用方时读取这些信息,但输出 JavaScript 时不会把声明变成值;这属于 类型擦除(type erasure) 。
这条边界很重要。declare function charge(): Receipt 的意思是“运行时别处已有这个函数”,而不是“请生成这个函数”。声明可以让编辑器补全和编译器检查变得准确,也可以用一份写错的契约让整个项目产生错误信心。
你会在三类位置遇到声明文件。JavaScript 包可以随包发布自己的类型;没有内置类型的包可以由 @types 包补充;应用也可以用本地声明描述构建工具注入的全局值或非代码资源。用 TypeScript 写库时,通常不应手写整份公开声明,而应让编译器从源码生成。
声明文件不验证网络响应、配置文件或任意 JavaScript 值。外部数据仍应先以 unknown 进入运行时验证,再获得领域类型。声明解决的是“检查器如何理解现有 API”,不是“这个值在运行时是否可信”。
对使用方来说,目标是找到与依赖版本匹配的声明并确认解析结果。对库作者来说,目标是发布一份可由消费者路径访问、并且与每个运行时入口一致的契约。
工作原理
TypeScript 同时处理两个相互关联的图:运行时模块图决定 JavaScript 会加载什么,类型图决定检查器看到什么。声明文件位于类型图中,却必须忠实描述运行时图。导出名称、默认导出、调用方式和模块格式中任何一项不一致,都会让编译通过的代码在加载或调用时失败。
图中的两条路径必须在使用方汇合。检查器找到 formatInvoice 并不证明运行时加载器也能从同一模块取得这个名称。测试声明时既要运行 tsc,也要执行至少一个真实导入。
声明与实现的边界
环境声明(ambient declaration) 告诉检查器某个实体由其他代码提供。declare 后的变量不能带普通初始化器,函数不能带函数体,因为这两种写法都会越过“只描述、不实现”的边界。在 .d.ts 文件中,顶层声明本身就处于环境上下文,许多位置无需重复写 declare。
声明应描述使用方能够观察到的公开表面,而不是复制实现细节。未导出的辅助函数、缓存结构和私有局部变量不属于契约。返回对象是否只读、参数能否省略、函数是否会返回 undefined,则会改变使用方式,必须准确表达。
类型精确度不能高于运行时事实。如果 JavaScript 接受字符串或数字,就不能只声明字符串;如果函数可能找不到结果,就不能去掉 undefined。过宽的 any 会丢掉错误,过窄的签名则会拒绝合法调用,两者都是声明缺陷。
脚本、模块与全局作用域
声明文件没有顶层 import 或 export 时,顶层名称会进入全局作用域。这适合确实由脚本标签或宿主注入的传统全局 API,却很容易污染应用中每个文件的名称空间。现代包通常应该是模块声明文件,并从顶层显式导出公开成员。
任意顶层 import 或 export 都会让文件成为模块。需要从模块文件补充全局名称时,先用 export {} 建立模块边界,再把补充项放入 declare global {}。这样可以看出哪些内容有意进入全局范围。
字符串形式的 declare module "package-name" 可以描述没有类型的外部模块。在已经解析到某个模块的文件中,相似语法也可用于模块增强;后者只扩展现有声明,不能替换默认导出或凭空实现运行时成员。模块增强的完整规则属于相邻主题。
声明合并只发生在允许合并的声明种类上。把两个同名 interface 放进同一作用域可能合并成员,但 type 别名不会这样做。不要依赖偶然的全局合并来拼接包 API;让文件与模块所有权保持明确。
编译器如何找到声明
对相对导入,TypeScript 会按照当前 moduleResolution 模式模拟宿主的查找方式,再通过扩展名替换寻找 .ts 或对应声明文件。例如,运行时目标是 ./invoice-tools.js 时,检查器可以读取同位置的 invoice-tools.d.ts。这不会改变发给运行时的模块说明符。
对包导入,package.json 的 exports、types、模块格式和解析模式共同决定结果。没有读取 exports 时,包根的 types 字段是明确的声明入口;存在 exports 时,每个公开子路径也应映射到与其运行时目标相符的类型。不能只给包根加一份 index.d.ts,却遗漏已经导出的子路径。
可见的 node_modules/@types 包可以提供全局类型和包声明。compilerOptions.types 限制哪些 @types 包进入全局作用域,typeRoots 则改变作为类型包根目录的位置;两者都不应该被当成普通路径别名。遇到解析问题时,用 tsc --traceResolution 查看实际候选路径,不要凭一份固定“查找顺序”猜测。
解析成功只说明检查器找到了一份声明。它不说明该声明与当前 JavaScript 版本匹配,也不说明运行时能加载同一入口。包升级、条件导出和双 ESM/CommonJS 发布都需要契约测试覆盖。
示例
下面四个示例沿着同一条工作流展开:先给现有 JavaScript 模块补类型,再声明一个真实全局值,然后从 TypeScript 源码生成声明,最后用类型测试防止契约漂移。示例中的实现与声明是同一个小型工作区内的文件。
为 JavaScript 模块手写声明
旧模块已经在运行时导出 currency 和 formatInvoice。实现保留在 JavaScript 中;声明文件只记录调用者可见的形状。
export const currency = "EUR";
export function formatInvoice(invoice) {
const total = Number(invoice.total);
if (!Number.isFinite(total)) {
throw new TypeError("invoice.total must be finite");
}
return `${invoice.id}: ${currency} ${total.toFixed(2)}`;
}export interface Invoice {
readonly id: string;
readonly total: number;
}
export declare const currency: "EUR";
export declare function formatInvoice(
invoice: Invoice,
): string;import {
currency,
formatInvoice,
type Invoice,
} from "./invoice-tools.js";
const invoice: Invoice = { id: "INV-104", total: 58 };
console.log(formatInvoice(invoice));
console.log(`settlement currency: ${currency}`);INV-104: EUR 58.00
settlement currency: EUR声明中的导出名称与 JavaScript 完全一致,使用方也保留 .js 模块说明符。Invoice 只存在于类型层,因此用 import type 表达这一点。currency 则是运行时值,不能只按类型导入。
字面量类型 "EUR" 来自真实常量,而不是作者的愿望。如果实现以后允许配置货币,声明也应改为更宽的类型。反过来,声明增加一个 JavaScript 没有导出的函数,也不会让该函数在运行时出现。
声明宿主提供的全局值
有些构建系统会在应用启动前提供构建信息。本地声明让业务代码知道其形状,但示例仍显式安装运行时值,避免把声明误当成实现。
export {};
declare global {
var BUILD_INFO: Readonly<{
version: string;
channel: "stable" | "canary";
builtAt: string;
}>;
}/// <reference path="./globals.d.ts" />
globalThis.BUILD_INFO = Object.freeze({
version: "6.2.0",
channel: "stable",
builtAt: "2026-09-04T08:30:00Z",
});
const label = `${BUILD_INFO.version} (${BUILD_INFO.channel})`;
console.log(label);
console.log(BUILD_INFO.builtAt);6.2.0 (stable)
2026-09-04T08:30:00Zexport {} 阻止 Readonly 之外的辅助名称意外进入全局范围,declare global 则明确打开需要补充的部分。这里使用 var 是因为全局 var 声明会对应 globalThis 的属性;这不是鼓励在应用逻辑中随意使用可变全局状态。
测试环境、服务端渲染和命令行脚本也必须安装同样的值,或者在启动时检查它是否存在。声明不能替代这一步。若该数据可以通过普通模块导入,模块通常比全局名称更容易追踪和测试。
从类型化源码生成声明
库源码已经是 TypeScript 时,让编译器生成声明可以减少重复维护。下面的模块同时包含公开接口、常量和函数;运行代码验证行为,随后单独检查声明输出。
export interface InventoryItem {
readonly sku: string;
readonly available: number;
}
export const inventoryVersion = 3 as const;
export function stockLabel(item: InventoryItem): string {
const state = item.available > 0 ? "in stock" : "back order";
return `${item.sku}: ${state}`;
}
const sample: InventoryItem = {
sku: "KB-87",
available: 12,
};
console.log(stockLabel(sample));KB-87: in stocknpx tsc inventory.ts \
--declaration --emitDeclarationOnly \
--module nodenext --moduleResolution nodenext \
--target es2022 --outDir dist
sed -n '1,80p' dist/inventory.d.tsexport interface InventoryItem {
readonly sku: string;
readonly available: number;
}
export declare const inventoryVersion: 3;
export declare function stockLabel(item: InventoryItem): string;declaration 启用 声明输出(declaration emit) ,emitDeclarationOnly 则不输出 JavaScript。若另一个工具负责转译 JavaScript,这组配置很合适;若 tsc 同时负责运行时代码,就不能只输出声明。
生成结果保留公开签名,丢掉函数体和示例调用。inventoryVersion 保留字面量 3,因为源码用 as const 固定了公开值。每次发布前都应检查生成产物是否包含在包中,不能只在本地生成后被 .gitignore 或打包白名单漏掉。
用类型测试锁定契约
运行测试只能证明 JavaScript 对少量输入的行为,类型测试还要证明合法调用被接受、非法调用被拒绝。@ts-expect-error 比 @ts-ignore 更适合负向用例,因为预期错误消失时,测试本身会失败。
import {
formatInvoice,
type Invoice,
} from "./invoice-tools.js";
const valid: Invoice = { id: "INV-105", total: 21.5 };
if (false) {
// @ts-expect-error total 必须是 number。
formatInvoice({ id: "INV-106", total: "21.5" });
// @ts-expect-error id 是必填字段。
formatInvoice({ total: 21.5 });
}
console.log(formatInvoice(valid));INV-105: EUR 21.50这个文件需要先用 tsc --noEmit 检查,再执行成功路径。只用 tsx 运行并不足够,因为它转译并执行代码,却不替代完整类型检查。测试还应从安装后的包入口导入,而不是绕过 exports 直接读取源码。
负向测试应对应真实契约,不要为了提高数量而罗列随意错误。包支持多个子路径或两种模块格式时,每个公开入口至少需要一次成功导入;容易漂移的重载、泛型推断和可选属性则需要针对性用例。
陷阱
手写 .d.ts 不会与 JavaScript 自动同步。修复方法: 能生成就从发布源码生成;必须手写时,同时运行真实导入测试,并把返回值、异常、可选成员和异步行为逐项与实现对照。
这种错误在单个项目中可能安静通过,到另一个依赖组合中才出现重复或被扩宽的成员。修复方法: 模块型声明使用顶层 export;确实需要补充全局时使用 export {} 加 declare global,并保持补充面最小。
占位声明适合极短的迁移接缝,不适合发布契约。修复方法: 从实际使用的最小 API 开始写精确签名,让未知数据保持 unknown,再随着覆盖面扩大补充成员;不要声称尚未检查的 API 已有类型。
该选项跳过声明文件内部的完整检查,主要用于处理依赖图中的重复或不一致类型;它不会验证声明与 JavaScript 是否一致。修复方法: 发布库时单独检查自己的声明和类型测试,并追踪被跳过的上游问题,不能用一个全局开关替代修复。
典型结果是编辑器接受命名导入,Node 却只得到默认值,或者某个 exports 子路径在检查时存在、运行时不可达。修复方法: 从打包后的临时安装目录分别测试 ESM 和 CommonJS 入口;让 .d.mts、.d.cts 或受包 type 约束的 .d.ts 与对应 JavaScript 格式一致。
让发布产物保持同一份契约
包入口是一张映射表
类型入口不能脱离运行时入口单独设计。包只有一个根入口时,types 可以指向根声明;包使用 exports 公开子路径时,每个子路径都要同时考虑加载条件和类型条件。未出现在 exports 中的文件,即使物理上存在,也可能不允许消费者导入。
下面的结构为包根提供 ESM 和 CommonJS 两套产物,并让对应声明采用不同扩展名。types 保留明确的顶层提示,而 exports 决定支持该机制的解析器实际可见的入口。
{
"name": "invoice-kit",
"version": "2.0.0",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist"]
}配置正确仍不代表文件存在。发布检查应先生成 tarball 或临时安装包,再从包外部执行两种导入。这样才能发现工作区路径别名、未打包声明和错误大小写等只在消费端出现的问题。
若 ESM 与 CommonJS 暴露的 API 确实相同,两份声明可以由同一源码生成或经过受控复制,但扩展名仍表达模块格式。不要只把文件改名而跳过两种消费者测试。默认导出与 export = 尤其容易在互操作选项下产生假阳性。
声明扩展名携带模块信息
在 Node 风格解析中,.d.mts 总是描述 ESM 对应的 .mjs,.d.cts 总是描述 CommonJS 对应的 .cjs。普通 .d.ts 对应 .js,其模块格式还会受最近的 package.json 中 type 字段影响。扩展名是解析契约的一部分,不是任意命名习惯。
这也是复制一份 .d.ts 给所有构建目标可能失败的原因。声明使用 ESM 导出语法,不足以证明它会在所有上下文按 ESM 解释;解析模式和包边界也参与判断。诊断模块格式问题时,应记录消费者配置、模块说明符、命中的 package.json 和最终文件路径。
浏览器打包器、Node 和测试运行器可能使用不同解析条件。库应声明自己支持哪些环境,并用对应的最小消费者项目验证,而不是堆叠兼容选项直到诊断消失。应用项目则应选择与真实宿主相符的 module 和 moduleResolution。
生成声明仍需要 API 审查
从源码生成能消除大量拼写与成员漂移,却不会自动得到理想的公共 API。推断出的返回类型可能泄漏内部类,导出的函数可能引用没有稳定名称的类型,常量也可能被推断得比兼容性策略更窄。生成后的差异应像 JavaScript 产物一样接受代码审查。
TypeScript 源码使用 declaration: true 生成类型。JavaScript 源码也可以结合 allowJs、JSDoc、declaration 和 emitDeclarationOnly 生成声明,但输出质量取决于 JSDoc 是否准确。无论输入语言是什么,未导出的实现语句不会作为可调用实现出现在声明中。
大型构建可以采用支持隔离声明生成的工具链,但发布契约仍须由 TypeScript 消费者验证。工具更快不等于签名正确。若不同生成器输出不一致,应先缩小到一个公开声明的输入与期望结果,而不是在发布阶段手工修补产物。
类型测试验证接受与拒绝
正向类型测试证明预期用法能编译,并检查推断结果没有意外变宽。负向测试用 @ts-expect-error 记录必须拒绝的调用;如果声明后来宽到接受该调用,编译器会报告这条指令没有对应错误。两者一起定义兼容性边界。
测试不应只从源码目录导入。消费者真正使用包根、子路径和条件导出,声明测试也应走这些路径。若包声称支持多个 TypeScript 版本,还要在相应版本矩阵中运行测试;使用较新语法的声明可能让旧编译器在读取文件阶段就失败。
类型测试不能发现实现返回了错误数据,因此还需要运行时契约测试。一条实用的回归测试会让同一个公开调用先通过 tsc,再加载发布后的 JavaScript 并断言结果。两条检查共享场景,却验证不同失败面。
选择内置类型、@types 或本地声明
使用依赖前,先查看包是否已经发布自己的声明。若有,通常不应再安装同名 @types 包;两套所有者不同的声明容易重复或版本错位。包没有内置类型时,再查找与运行时主版本匹配的 @types 包。
公开生态中的缺失类型可以贡献给 DefinitelyTyped,本地或私有 API 则适合保留在应用自己的声明目录。无论放在哪里,声明版本都应跟随它描述的 JavaScript 契约,而不是只跟随使用方的编译器版本。
临时本地声明要标出覆盖范围和删除条件。它可以先描述项目实际使用的两个函数,而不必虚构完整库;当上游发布正式类型后,应通过类型测试比较差异,再移除本地补丁。长期叠加两个来源只会让声明合并掩盖所有权。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题